From 1474dee07918c1c9068ca96d949e04514e17f8e0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 17:05:46 +0200 Subject: [PATCH 01/76] fix(user): bind chat and call screens to their account Chat and call screens read the global active user instead of the account they were opened for. Any setUserAsActive() while such a screen was open (e.g. an incoming call for another account) silently switched it to the other account or mixed data and credentials of both. - ChatActivity/ChatViewModel are bound to the user id passed in the intent (KEY_INTERNAL_USER_ID); all chat launches go through ChatActivity.createIntent() and pass the user id - CallActivity, RaiseHandViewModel and CallRecordingViewModel use the user of the call - NotificationWorker no longer switches the active account on incoming calls - UserManager.userFlow(id) observes a single user - getActiveUser() no longer writes to the database on every read; the "multiple active users" self-heal runs once at app start - setUserAsActive() publishes the stored row - CurrentUserProviderOldImpl: fix racy cache initialization Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 20 ++++-- .../nextcloud/talk/activities/CallActivity.kt | 19 +++--- .../nextcloud/talk/activities/MainActivity.kt | 15 ++--- .../application/NextcloudTalkApplication.kt | 9 +++ .../com/nextcloud/talk/chat/ChatActivity.kt | 59 ++++++++++++------ .../talk/chat/ScheduledMessagesActivity.kt | 15 ++--- .../talk/chat/viewmodels/ChatViewModel.kt | 21 ++++--- .../talk/contacts/ContactsViewModel.kt | 4 +- .../contacts/components/ContactItemRow.kt | 15 ++--- .../ConversationCreationActivity.kt | 2 +- .../ui/CreatedConversation.kt | 11 +--- .../ConversationCreationViewModel.kt | 4 +- .../ConversationInfoActivity.kt | 5 +- .../ConversationsListActivity.kt | 32 ++++------ .../com/nextcloud/talk/data/user/UsersDao.kt | 6 ++ .../talk/data/user/UsersRepository.kt | 2 + .../talk/data/user/UsersRepositoryImpl.kt | 13 ++-- .../talk/invitation/InvitationsActivity.kt | 12 ++-- .../nextcloud/talk/jobs/NotificationWorker.kt | 4 +- .../ListOpenConversationsActivity.kt | 11 +--- .../raisehand/viewmodel/RaiseHandViewModel.kt | 12 ++-- .../activities/SharedItemsActivity.kt | 1 + .../adapters/SharedItemsAdapter.kt | 4 +- .../ThreadsOverviewActivity.kt | 9 +-- .../com/nextcloud/talk/users/UserManager.kt | 29 ++++++++- .../user/CurrentUserProviderOldImpl.kt | 27 +++----- .../talk/utils/preview/ComposePreviewUtils.kt | 3 +- .../utils/preview/ComposePreviewUtilsDaos.kt | 4 ++ .../talk/viewmodels/CallRecordingViewModel.kt | 14 ++--- .../talk/data/user/UsersRepositoryImplTest.kt | 35 +++++++++++ .../nextcloud/talk/users/UserManagerTest.kt | 61 +++++++++++++++++++ .../viewmodels/CallRecordingViewModelTest.kt | 40 ++++-------- 32 files changed, 321 insertions(+), 197 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 366c7989b2b..e43567dcd8b 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -96,6 +96,17 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var logger: Logger + /** + * Internal id of the account this activity was started for, taken from [BundleKeys.KEY_INTERNAL_USER_ID]. + * Falls back to the default account for launches without it and writes the id back to the intent, so a + * recreated activity stays on the same account. Returns 0 if there is no account at all. + */ + protected fun resolveUserIdFromIntent(): Long = + intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L).takeIf { it != 0L } + ?: (currentUserProviderOld.currentUser.blockingGet()?.id ?: 0L).also { + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) + } + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR @@ -344,10 +355,11 @@ open class BaseActivity : AppCompatActivity() { fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileIdNew(uri)) } else if (UriUtils.isInstanceInternalTalkUrl(user.baseUrl!!, uri)) { // https://cloud.nextcloud.com/call/123456789 - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, UriUtils.extractRoomTokenFromTalkUrl(uri)) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent( + context, + user.id!!, + UriUtils.extractRoomTokenFromTalkUrl(uri) + ) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } else { diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index cc3b96651c4..737ce78fe9d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -169,6 +169,7 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import okhttp3.Cache import org.apache.commons.lang3.StringEscapeUtils import org.greenrobot.eventbus.Subscribe @@ -535,7 +536,13 @@ class CallActivity : CallBaseActivity() { return } processExtras(intent.extras!!) - conversationUser = currentUserProviderOld.currentUser.blockingGet() + val user = runBlocking { userManager!!.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + Log.e(TAG, "No user found for call") + finish() + return + } + conversationUser = user if (warnAndFinishIfCallEndToEndEncryptionUnsupported()) { return @@ -576,7 +583,7 @@ class CallActivity : CallBaseActivity() { callRecordingViewModel = ViewModelProvider(this, viewModelFactory).get( CallRecordingViewModel::class.java ) - callRecordingViewModel!!.setData(roomToken!!) + callRecordingViewModel!!.setData(conversationUser, roomToken!!) callRecordingViewModel!!.setRecordingState(recordingState) callRecordingViewModel!!.viewState.observe(this) { viewState: CallRecordingViewModel.ViewState? -> if (viewState is RecordingStartedState) { @@ -634,7 +641,7 @@ class CallActivity : CallBaseActivity() { private fun initRaiseHandViewModel() { raiseHandViewModel = ViewModelProvider(this, viewModelFactory).get(RaiseHandViewModel::class.java) - raiseHandViewModel!!.setData(roomToken!!, isBreakoutRoom) + raiseHandViewModel!!.setData(conversationUser, roomToken!!, isBreakoutRoom) raiseHandViewModel!!.viewState.observe(this) { viewState: RaiseHandViewModel.ViewState? -> var raised = false if (viewState is RaisedHandState) { @@ -2324,14 +2331,12 @@ class CallActivity : CallBaseActivity() { openConversationListInPrimaryTask() finishAndRemoveTask() } else if (switchToRoomToken.isNotEmpty()) { - val intent = Intent(context, ChatActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) val bundle = Bundle() bundle.putBoolean(KEY_SWITCH_TO_ROOM, true) bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) - bundle.putString(KEY_ROOM_TOKEN, switchToRoomToken) bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) - intent.putExtras(bundle) + val intent = ChatActivity.createIntent(context, conversationUser.id!!, switchToRoomToken, bundle) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) finish() } else if (shutDownView) { diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 16606934f97..b481a6e62b3 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -41,7 +41,6 @@ import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers @@ -209,11 +208,11 @@ class MainActivity : .subscribe( { roomOverall -> if (isFinishing || isDestroyed) return@subscribe - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) - - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent( + context, + currentUser.id!!, + roomOverall.ocs!!.data!!.token + ) startActivity(chatIntent) }, { e -> @@ -339,9 +338,7 @@ class MainActivity : listIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) listIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtra(KEY_ROOM_TOKEN, deepLinkResult.roomToken) - chatIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id) + val chatIntent = ChatActivity.createIntent(context, targetUser.id!!, deepLinkResult.roomToken) startActivities(arrayOf(listIntent, chatIntent)) } else { diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index b8bc0993fe0..cec49766a69 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -39,6 +39,7 @@ import com.nextcloud.talk.diagnosis.toMarkdown import com.nextcloud.talk.errorhandling.ExceptionHandler import com.nextcloud.talk.logger.LoggerImpl import com.nextcloud.talk.account.BrowserLoginActivity +import com.nextcloud.talk.dagger.modules.ApplicationScope import com.nextcloud.talk.dagger.modules.BusModule import com.nextcloud.talk.dagger.modules.ContextModule import com.nextcloud.talk.dagger.modules.DaosModule @@ -65,6 +66,8 @@ import de.cotech.hw.SecurityKeyManager import de.cotech.hw.SecurityKeyManagerConfig import io.reactivex.exceptions.UndeliverableException import io.reactivex.plugins.RxJavaPlugins +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.conscrypt.Conscrypt import org.webrtc.PeerConnectionFactory @@ -112,6 +115,10 @@ class NextcloudTalkApplication : @Inject lateinit var userManager: UserManager + @Inject + @field:ApplicationScope + lateinit var appScope: CoroutineScope + @Inject lateinit var logger: LoggerImpl //endregion @@ -240,6 +247,8 @@ class NextcloudTalkApplication : } private fun initWorkers() { + appScope.launch { userManager.repairMultipleActiveUsers() } + val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build() val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesSyncWorker::class.java).build() val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build() diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 164916cc032..79de978b140 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -179,6 +179,7 @@ import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.LocalMessageUtils import com.nextcloud.talk.ui.theme.LocalOpenGraphFetcher import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -241,6 +242,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -293,9 +295,13 @@ class ChatActivity : @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory + @Inject + lateinit var userManager: UserManager + val chatViewModel: ChatViewModel by viewModels { ViewModelFactoryWithParams(ChatViewModel::class.java) { chatViewModelFactory.build( + conversationUserId, roomToken, conversationThreadId ) @@ -362,6 +368,8 @@ class ChatActivity : ?: error("roomToken missing") } + val conversationUserId: Long by lazy { resolveUserIdFromIntent() } + val conversationThreadId: Long? by lazy { if (intent.hasExtra(KEY_THREAD_ID)) { intent.getLongExtra(KEY_THREAD_ID, 0L) @@ -635,8 +643,10 @@ class ChatActivity : overflowContainerHeightPx.intValue = binding.chatOverflowContainer.height } + // Resolved synchronously: onStart()/onResume() rely on initData() having run before they are called. + val boundUser = runBlocking { userManager.getUserWithId(conversationUserId) } lifecycleScope.launch { - currentUserProvider.getCurrentUser() + runCatching { checkNotNull(boundUser) { "User not found" } } .onSuccess { user -> conversationUser = user handleIntent(intent) @@ -1286,10 +1296,7 @@ class ChatActivity : retrofitBucket.url, retrofitBucket.queryMap ) - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) - val chatIntent = Intent(this@ChatActivity, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(this@ChatActivity, user.id!!, roomOverall.ocs!!.data!!.token) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } catch (e: Exception) { @@ -1370,6 +1377,14 @@ class ChatActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, conversationUserId) + if (newUserId != conversationUserId) { + // A chat instance is bound to one account, so open a fresh instance for the other one. + intent.removeFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + finish() + startActivity(intent) + return + } val extras: Bundle? = intent.extras val requestedRoomSwitch = extras?.getBoolean(KEY_SWITCH_TO_ROOM, false) == true @@ -1820,12 +1835,10 @@ class ChatActivity : chatViewModel.createRoomViewState.observe(this) { state -> when (state) { is ChatViewModel.CreateRoomSuccessState -> { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token) + val token = state.roomOverall.ocs!!.data!!.token leaveRoom { - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, token) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } @@ -2362,7 +2375,6 @@ class ChatActivity : } val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, token) if (startCallAfterRoomSwitch) { bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) @@ -2370,8 +2382,7 @@ class ChatActivity : } leaveRoom { - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, token, bundle) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } @@ -2980,6 +2991,7 @@ class ChatActivity : val intent = Intent(this, ConversationInfoActivity::class.java) intent.putExtras(bundle) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startActivity(intent) } @@ -3231,6 +3243,7 @@ class ChatActivity : private fun openScheduledMessages() { val intent = Intent(this, ScheduledMessagesActivity::class.java).apply { putExtra(ScheduledMessagesActivity.ROOM_TOKEN, roomToken) + putExtra(KEY_INTERNAL_USER_ID, conversationUserId) putExtra(ScheduledMessagesActivity.CONVERSATION_NAME, currentConversation?.displayName.orEmpty()) if (conversationThreadId != null && conversationThreadId!! > 0) { putExtra(ScheduledMessagesActivity.THREAD_ID, conversationThreadId) @@ -3471,6 +3484,7 @@ class ChatActivity : private fun showSharedItems() { val intent = Intent(this, SharedItemsActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra( @@ -3556,6 +3570,7 @@ class ChatActivity : val callIntent = Intent(this, CallActivity::class.java) callIntent.putExtras(bundle) + callIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) return callIntent } ?: run { return null @@ -4022,10 +4037,7 @@ class ChatActivity : } fun openNoteToSelfConversation(noteToSelfRoomToken: String) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, noteToSelfRoomToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, noteToSelfRoomToken) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } @@ -4196,10 +4208,8 @@ class ChatActivity : fun openThread(messageId: Long) { val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomToken) bundle.putLong(KEY_THREAD_ID, messageId) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, roomToken, bundle) startActivity(chatIntent) } @@ -4217,6 +4227,7 @@ class ChatActivity : val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java) threadsOverviewIntent.putExtras(bundle) + threadsOverviewIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startActivity(threadsOverviewIntent) } @@ -4303,6 +4314,16 @@ class ChatActivity : companion object { val TAG = ChatActivity::class.java.simpleName + + /** + * Creates an intent that opens the conversation [roomToken] of the account with the internal id [userId]. + */ + fun createIntent(context: Context, userId: Long, roomToken: String, extras: Bundle? = null): Intent = + Intent(context, ChatActivity::class.java).apply { + extras?.let { putExtras(it) } + putExtra(KEY_ROOM_TOKEN, roomToken) + putExtra(KEY_INTERNAL_USER_ID, userId) + } private const val CONTENT_TYPE_CALL_STARTED: Byte = 1 private const val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 2 private const val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 3 diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index b46932a1df9..aed09f6ba88 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -9,7 +9,6 @@ package com.nextcloud.talk.chat import android.content.ClipData import android.content.ClipboardManager -import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.annotation.DrawableRes @@ -110,7 +109,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -217,8 +215,8 @@ class ScheduledMessagesActivity : BaseActivity() { } private fun openThread(threadId: Long) { - val intent = Intent(this, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, roomToken) + val user = scheduledMessagesViewModel.currentUserState.value ?: return + val intent = ChatActivity.createIntent(this, user.id!!, roomToken).apply { putExtra(KEY_THREAD_ID, threadId) } startActivity(intent) @@ -981,17 +979,16 @@ class ScheduledMessagesActivity : BaseActivity() { } private fun openParentMessage(messageId: Long?) { - val intent = Intent(this, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, roomToken) - + val user = scheduledMessagesViewModel.currentUserState.value ?: return + val intent = ChatActivity.createIntent(this, user.id!!, roomToken).apply { messageId?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it.toString()) } } startActivity(intent) } private fun openThreadParentMessage(messageId: Long?, threadId: Long?) { - val intent = Intent(this, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, roomToken) + val user = scheduledMessagesViewModel.currentUserState.value ?: return + val intent = ChatActivity.createIntent(this, user.id!!, roomToken).apply { threadId?.let { putExtra(BundleKeys.KEY_THREAD_ID, it) } messageId?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it.toString()) } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 5a3e643797e..deb68feb827 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -70,6 +70,7 @@ import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.threadsoverview.data.ThreadsRepository import com.nextcloud.talk.ui.PlaybackSpeed +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CharacterAvatarUtils @@ -81,7 +82,6 @@ import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.throttleLatest import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.message.SendMessageUtils import com.nextcloud.talk.utils.message.groupHashOf import com.nextcloud.talk.utils.preferences.AppPreferences @@ -272,8 +272,9 @@ class ChatViewModel @AssistedInject constructor( private val unifiedSearchRepository: UnifiedSearchRepository, private val mediaRecorderManager: MediaRecorderManager, private val audioFocusRequestManager: AudioFocusRequestManager, - private val currentUserProvider: CurrentUserProvider, + private val userManager: UserManager, @ApplicationScope private val appScope: CoroutineScope, + @Assisted private val internalUserId: Long, @Assisted private val chatRoomToken: String, @Assisted private val conversationThreadId: Long? ) : ViewModel(), @@ -680,18 +681,20 @@ class ChatViewModel @AssistedInject constructor( // Current user flows // ------------------------------ private val currentUserFlow: StateFlow = - currentUserProvider.currentUserFlow + userManager.userFlow(internalUserId) .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val nonNullUserFlow = currentUserFlow.filterNotNull() + // Emits once per bound user, so updates of the user row (e.g. capabilities) don't restart dependent flows. + private val boundUserFlow = nonNullUserFlow.distinctUntilChangedBy { it.id } + // Unlike conversationFlow below, this is not deduped by lastReadMessage/lastCommonReadMessage, // so it also reacts to fields those two ignore, e.g. hasCall (see [hasCall]). private val rawConversationFlow: Flow = - nonNullUserFlow + boundUserFlow .flatMapLatest { user -> - val userId = requireNotNull(user.id) - conversationRepository.observeConversation(userId, chatRoomToken) + conversationRepository.observeConversation(requireNotNull(user.id), chatRoomToken) } .mapNotNull { result -> when (result) { @@ -1149,7 +1152,7 @@ class ChatViewModel @AssistedInject constructor( } private fun observeLobbyState() { - val conversationFromDb = nonNullUserFlow + val conversationFromDb = boundUserFlow .flatMapLatest { user -> conversationRepository.observeConversation(requireNotNull(user.id), chatRoomToken) } @@ -1189,7 +1192,7 @@ class ChatViewModel @AssistedInject constructor( } private fun observePinnedMessage() { - nonNullUserFlow + boundUserFlow .flatMapLatest { user -> conversationRepository.observeConversation(requireNotNull(user.id), chatRoomToken) .mapNotNull { result -> @@ -2829,6 +2832,6 @@ class ChatViewModel @AssistedInject constructor( @AssistedFactory interface ChatViewModelFactory { - fun build(roomToken: String, conversationThreadId: Long?): ChatViewModel + fun build(internalUserId: Long, roomToken: String, conversationThreadId: Long?): ChatViewModel } } diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt index 184a65550ed..d4ed662381a 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt @@ -198,7 +198,7 @@ class ContactsViewModel @Inject constructor( ) val conversation: ConversationDto? = room.ocs?.data - _roomViewState.value = RoomUiState.Success(conversation) + _roomViewState.value = RoomUiState.Success(user.id!!, conversation) } catch (exception: Exception) { logger.e(TAG, "Failed to create room", exception) _roomViewState.value = RoomUiState.Error(exception.message ?: "") @@ -225,7 +225,7 @@ class ContactsViewModel @Inject constructor( sealed class RoomUiState { data object None : RoomUiState() - data class Success(val conversation: ConversationDto?) : RoomUiState() + data class Success(val userId: Long, val conversation: ConversationDto?) : RoomUiState() data class Error(val message: String) : RoomUiState() } } diff --git a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt index a37193f2601..65e8d6e08b9 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt @@ -10,7 +10,6 @@ package com.nextcloud.talk.contacts.components import android.content.Context import android.content.Intent -import android.os.Bundle import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -42,7 +41,6 @@ import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.bundle.BundleKeys @Suppress("LongMethod") @Composable @@ -99,13 +97,12 @@ fun ContactItemRow(contact: AutocompleteUserDto, contactsViewModel: ContactsView } when (roomUiState) { is ContactsViewModel.RoomUiState.Success -> { - val conversation = (roomUiState as ContactsViewModel.RoomUiState.Success).conversation - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversation?.token) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - context.startActivity(chatIntent) + val successState = roomUiState as ContactsViewModel.RoomUiState.Success + successState.conversation?.token?.let { token -> + val chatIntent = ChatActivity.createIntent(context, successState.userId, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + context.startActivity(chatIntent) + } } is ContactsViewModel.RoomUiState.Error -> { val errorMessage = (roomUiState as ContactsViewModel.RoomUiState.Error).message diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index e42ac913172..c74527c0dc7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -806,7 +806,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo onDismiss = { roomToken -> createdPublicConversation = null conversationCreationViewModel.clearCreationState() - openConversation(context, roomToken) + currentUser?.id?.let { openConversation(context, it, roomToken) } } ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt index fcc82073ad7..e47b3812c51 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt @@ -10,7 +10,6 @@ package com.nextcloud.talk.conversationcreation.ui import android.app.Activity import android.content.Context import android.content.Intent -import android.os.Bundle import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog @@ -28,7 +27,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ShareUtils -import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.copyPasswordToClipboard private val TAG = ConversationCreationActivity::class.java.simpleName @@ -69,7 +67,7 @@ fun CreationResultEffect( onHandled() } else { onHandled() - openConversation(context, roomToken) + openConversation(context, state.userId, roomToken) } } } @@ -128,11 +126,8 @@ fun ShareCreatedConversation( ) } -fun openConversation(context: Context, roomToken: String) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) +fun openConversation(context: Context, userId: Long, roomToken: String) { + val chatIntent = ChatActivity.createIntent(context, userId, roomToken) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) context.startActivity(chatIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index f7b9e47bfc1..8753b69f431 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -277,7 +277,7 @@ class ConversationCreationViewModel @Inject constructor( ) val token = conversation?.token if (!token.isNullOrEmpty()) { - roomViewState.value = RoomUIState.Success(conversation) + roomViewState.value = RoomUIState.Success(user.id!!, conversation) } else { roomViewState.value = RoomUIState.Error() Log.e(TAG, "The created conversation came back without a token") @@ -311,7 +311,7 @@ sealed interface PresetsUiState { sealed class RoomUIState { data object None : RoomUIState() - data class Success(val conversation: ConversationDto?) : RoomUIState() + data class Success(val userId: Long, val conversation: ConversationDto?) : RoomUIState() data class Error(val serverMessage: String? = null) : RoomUIState() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 255f89e1def..aa5c4fe0e27 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -243,9 +243,7 @@ class ConversationInfoActivity : BaseActivity() { snackbarHostState.showSnackbar(event.text) is ConversationInfoUiEvent.NavigateToChat -> { startActivity( - Intent(this@ConversationInfoActivity, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, event.token) - } + ChatActivity.createIntent(this@ConversationInfoActivity, user.id!!, event.token) ) } ConversationInfoUiEvent.RefreshParticipants -> @@ -429,6 +427,7 @@ class ConversationInfoActivity : BaseActivity() { ) startActivity( Intent(context, ThreadsOverviewActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) putExtra(KEY_ROOM_TOKEN, conversationToken) putExtra(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads)) putExtra(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index d27efe82093..e5443d47d61 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -522,13 +522,11 @@ class ConversationsListActivity : BaseActivity() { contactsViewModel.roomViewState.onEach { state -> when (state) { is ContactsViewModel.RoomUiState.Success -> { - val conversation = state.conversation - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, conversation?.token) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) + state.conversation?.token?.let { token -> + val chatIntent = ChatActivity.createIntent(context, state.userId, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } } else -> {} @@ -599,11 +597,8 @@ class ConversationsListActivity : BaseActivity() { private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { if (noteToSelfAvailable) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, noteToSelfToken) - bundle.putBoolean(BundleKeys.KEY_FOCUS_INPUT, true) - val intent = Intent(context, ChatActivity::class.java) - intent.putExtras(bundle) + val intent = ChatActivity.createIntent(context, currentUser!!.id!!, noteToSelfToken) + intent.putExtra(BundleKeys.KEY_FOCUS_INPUT, true) intent.action = Intent.ACTION_VIEW val openNotesString = resources.getString(R.string.open_notes) @@ -646,8 +641,7 @@ class ConversationsListActivity : BaseActivity() { return } val threadId = result.threadId?.toLongOrNull()?.takeIf { it > 0L } - val intent = Intent(context, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, result.conversationToken) + val intent = ChatActivity.createIntent(context, currentUser!!.id!!, result.conversationToken).apply { putExtra(BundleKeys.KEY_MESSAGE_ID, messageId) putExtra(BundleKeys.KEY_SEARCH_QUERY, result.searchTerm) threadId?.let { putExtra(BundleKeys.KEY_THREAD_ID, it) } @@ -1062,6 +1056,7 @@ class ConversationsListActivity : BaseActivity() { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, it.token) + bundle.putLong(KEY_INTERNAL_USER_ID, currentUser!!.id!!) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, "") bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, currentUser?.baseUrl!!) bundle.putString(KEY_CONVERSATION_NAME, it.displayName) @@ -1099,15 +1094,13 @@ class ConversationsListActivity : BaseActivity() { private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { selectedConversation?.let { val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, selectedConversation!!.token) bundle.putString(KEY_SHARED_TEXT, textToPaste) if (selectedMessageId != null) { bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId) selectedMessageId = null } - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent(context, currentUser!!.id!!, it.token, bundle) if (currentUser != null) { if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(currentUser?.capabilities?.spreedCapability)) { @@ -1137,7 +1130,6 @@ class ConversationsListActivity : BaseActivity() { } val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, selectedConversation!!.token) bundle.putString(KEY_SHARED_TEXT, textToPaste) if (!filesToShare.isNullOrEmpty()) { bundle.putStringArrayList(BundleKeys.KEY_SHARED_FILE_PATHS, filesToShare) @@ -1147,8 +1139,7 @@ class ConversationsListActivity : BaseActivity() { selectedMessageId = null } - val intent = Intent(context, ChatActivity::class.java) - intent.putExtras(bundle) + val intent = ChatActivity.createIntent(context, currentUser!!.id!!, selectedConversation!!.token, bundle) startActivity(intent) clearIntentAction() @@ -1569,6 +1560,7 @@ class ConversationsListActivity : BaseActivity() { bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java) threadsOverviewIntent.putExtras(bundle) + threadsOverviewIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(threadsOverviewIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt index 27f7019a1cc..465dd3fddbb 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt @@ -52,6 +52,12 @@ interface UsersDao { @Query("SELECT * FROM User where id = :id") suspend fun getUserWithId(id: Long): UserEntity? + @Query("SELECT * FROM User where id = :id") + fun getUserWithIdFlow(id: Long): Flow + + @Query("SELECT COUNT(*) FROM User where current = 1") + suspend fun getActiveUsersCount(): Int + @Query("SELECT * FROM User where id = :id AND scheduledForDeletion != 1") suspend fun getUserWithIdNotScheduledForDeletion(id: Long): UserEntity? diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt index ce15a57077e..5e5f62d9b8a 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt @@ -17,6 +17,8 @@ interface UsersRepository { fun getActiveUserFlow(): Flow suspend fun getUsers(): List suspend fun getUserWithId(id: Long): User? + fun getUserWithIdFlow(id: Long): Flow + suspend fun getActiveUsersCount(): Int suspend fun getUserWithIdNotScheduledForDeletion(id: Long): User? suspend fun getUserWithUserId(userId: String): User? suspend fun getUsersScheduledForDeletion(): List diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt index 5a53c3bbf8c..9418e1a1fb0 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt @@ -16,11 +16,7 @@ import kotlinx.coroutines.flow.map @Suppress("TooManyFunctions") class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { - override suspend fun getActiveUser(): User? { - val entity = usersDao.getActiveUser() ?: return null - setUserAsActiveWithId(entity.id) - return UserMapper.toModel(entity) - } + override suspend fun getActiveUser(): User? = UserMapper.toModel(usersDao.getActiveUser()) override fun getActiveUserFlow(): Flow = usersDao.getActiveUserFlow().map { @@ -31,6 +27,13 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { override suspend fun getUserWithId(id: Long): User? = UserMapper.toModel(usersDao.getUserWithId(id)) + override fun getUserWithIdFlow(id: Long): Flow = + usersDao.getUserWithIdFlow(id).map { + UserMapper.toModel(it) + } + + override suspend fun getActiveUsersCount(): Int = usersDao.getActiveUsersCount() + override suspend fun getUserWithIdNotScheduledForDeletion(id: Long): User? = UserMapper.toModel(usersDao.getUserWithIdNotScheduledForDeletion(id)) diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index ea954a6ed97..9bf6e154e98 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -26,7 +26,6 @@ import com.nextcloud.talk.invitation.adapters.InvitationsAdapter import com.nextcloud.talk.invitation.data.ActionEnum import com.nextcloud.talk.invitation.data.Invitation import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel -import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -136,12 +135,11 @@ class InvitationsActivity : BaseActivity() { is InvitationsViewModel.InvitationActionSuccessState -> { if (state.action == ActionEnum.ACCEPT) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, state.invitation.localToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) + state.invitation.localToken?.let { token -> + val chatIntent = ChatActivity.createIntent(context, currentUser.id!!, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } } else { // adapter.currentList.remove(state.invitation) // adapter.notifyDataSetChanged() // leads to UnsupportedOperationException ?! diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 546f87ad86c..b29a415f96b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -392,8 +392,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor null } - if (conversation != null && runBlocking { userManager.setUserAsActive(userBeingCalled!!) }) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled?.capabilities?.spreedCapability)) { + if (conversation != null && userBeingCalled != null) { + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled.capabilities?.spreedCapability)) { showEndToEndEncryptionUnsupportedNotification(conversation) } else { prepareCallNotificationScreen(conversation) diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index baa006739d1..2db5f0cb649 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -21,7 +21,6 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.utils.adjustUIForAPILevel35 -import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -58,7 +57,7 @@ class ListOpenConversationsActivity : BaseActivity() { openConversationsViewModel.updateSearchTerm(term) openConversationsViewModel.fetchConversations() }, - onConversationClick = { conversation -> navigateToChat(conversation) }, + onConversationClick = { conversation -> user?.id?.let { navigateToChat(it, conversation) } }, onBackClick = { onBackPressedDispatcher.onBackPressed() } ) ) @@ -66,12 +65,8 @@ class ListOpenConversationsActivity : BaseActivity() { } } - private fun navigateToChat(conversation: ConversationDto) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversation.token) - - val chatIntent = Intent(this, ChatActivity::class.java) - chatIntent.putExtras(bundle) + private fun navigateToChat(userId: Long, conversation: ConversationDto) { + val chatIntent = ChatActivity.createIntent(this, userId, conversation.token) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt b/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt index 3c6a670859a..1da3539e20e 100644 --- a/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt @@ -10,22 +10,19 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.raisehand.RequestAssistanceModel import com.nextcloud.talk.raisehand.RequestAssistanceRepository import com.nextcloud.talk.raisehand.WithdrawRequestAssistanceModel import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class RaiseHandViewModel @Inject constructor( - private val repository: RequestAssistanceRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class RaiseHandViewModel @Inject constructor(private val repository: RequestAssistanceRepository) : ViewModel() { @Inject lateinit var userManager: UserManager @@ -39,7 +36,7 @@ class RaiseHandViewModel @Inject constructor( object LoweredHandState : ViewState object ErrorState : ViewState - private val currentUser = currentUserProvider.currentUser.blockingGet() + private lateinit var currentUser: User private val _viewState: MutableLiveData = MutableLiveData(LoweredHandState) val viewState: LiveData @@ -101,7 +98,8 @@ class RaiseHandViewModel @Inject constructor( } } - fun setData(roomToken: String, isBreakoutRoom: Boolean) { + fun setData(user: User, roomToken: String, isBreakoutRoom: Boolean) { + this.currentUser = user this.roomToken = roomToken this.isBreakoutRoom = isBreakoutRoom } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index d80d0dd40e0..ee54ac4ee2d 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -62,6 +62,7 @@ class SharedItemsActivity : BaseActivity() { val chatViewModel: ChatViewModel by viewModels { ViewModelFactoryWithParams(ChatViewModel::class.java) { chatViewModelFactory.build( + currentUserProviderOld.currentUser.blockingGet().id!!, roomToken, conversationThreadId ) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index d62b965d8c2..0cad81cf089 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.shareditems.adapters import android.content.Context -import android.content.Intent import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -129,8 +128,7 @@ class SharedItemsAdapter( } private fun openInChat(item: SharedFileItem, context: Context) { - val intent = Intent(context, ChatActivity::class.java).apply { - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + val intent = ChatActivity.createIntent(context, user.id!!, roomToken).apply { putExtra(BundleKeys.KEY_MESSAGE_ID, item.messageId) } context.startActivity(intent) diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index a4570022832..7654190e416 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.threadsoverview -import android.content.Intent import android.os.Bundle import android.text.format.DateUtils import androidx.activity.compose.setContent @@ -50,7 +49,6 @@ import com.nextcloud.talk.models.json.threads.ThreadInfoDto import com.nextcloud.talk.threadsoverview.components.ThreadRow import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import javax.inject.Inject @@ -124,11 +122,8 @@ class ThreadsOverviewActivity : BaseActivity() { } private fun navigateToChatActivity(roomToken: String, threadId: Int) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomToken) - bundle.putLong(KEY_THREAD_ID, threadId.toLong()) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent(context, resolveUserIdFromIntent(), roomToken) + chatIntent.putExtra(KEY_THREAD_ID, threadId.toLong()) startActivity(chatIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index 5f1e454efd8..7d4b33c96aa 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -19,8 +19,10 @@ import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @Suppress("TooManyFunctions") @@ -33,10 +35,31 @@ class UserManager internal constructor(private val userRepository: UsersReposito suspend fun getUsersScheduledForDeletion(): List = userRepository.getUsersScheduledForDeletion() /** - * The active user, or - if none is active - any user not scheduled for deletion, which is then set as active. + * The default account, i.e. the last active user, or - if none is active - any user not scheduled for deletion, + * which is then set as active. + * + * Only for entry points that have no account context (app launch, share-to, deep links without account). + * Screens, workers and receivers must use the user they were started for, see [getUserWithId] and [userFlow]. */ suspend fun getCurrentUser(): User? = userRepository.getActiveUser() ?: getAnyUserAndSetAsActive() + /** + * Emits the user with the given internal id whenever its row changes, or null if it does not exist (anymore). + */ + fun userFlow(id: Long): Flow = userRepository.getUserWithIdFlow(id).distinctUntilChanged() + + /** + * Ensures that at most one user is marked as active. If several are, the one with the highest id is kept, + * matching the user returned by [getCurrentUser]. + */ + suspend fun repairMultipleActiveUsers() { + if (userRepository.getActiveUsersCount() > 1) { + val activeUser = userRepository.getActiveUser() ?: return + Log.w(TAG, "Multiple active users found, keeping ${activeUser.id} as active") + userRepository.setUserAsActiveWithId(activeUser.id!!) + } + } + /** * Backed by [activeUserStateFlow] rather than [UsersRepository.getActiveUserFlow] directly, so that * [setUserAsActive] can push the newly-active user out synchronously the moment it succeeds, instead of @@ -46,6 +69,8 @@ class UserManager internal constructor(private val userRepository: UsersReposito * knows about it - e.g. launching a screen for the new user before its avatar/data had actually updated. * Room's own [UsersRepository.getActiveUserFlow] is still relied on underneath to seed this and to catch * any change to the `current` flag that doesn't go through [setUserAsActive]. + * + * This is the default account, not the account of a running screen; screens must use [userFlow]. */ val currentUserFlow: StateFlow get() = activeUserStateFlow @@ -117,7 +142,7 @@ class UserManager internal constructor(private val userRepository: UsersReposito Log.d(TAG, "setUserAsActive:" + user.id!!) val success = userRepository.setUserAsActiveWithId(user.id!!) if (success) { - activeUserStateFlow.value = user + activeUserStateFlow.value = userRepository.getUserWithId(user.id!!) ?: user } return success } diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt index 7d525b73559..61d1bc5bcf3 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt @@ -12,7 +12,6 @@ import com.nextcloud.talk.users.UserManager import io.reactivex.Maybe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -29,27 +28,21 @@ class CurrentUserProviderOldImpl @Inject constructor(private val userManager: Us private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile private var _currentUser: User? = null - // synchronized to avoid multiple observers initialized from different threads - @get:Synchronized - @set:Synchronized - private var currentUserObserver: Job? = null + init { + scope.launch { + userManager.currentUserFlow.filterNotNull().collect { + _currentUser = it + } + } + } @Deprecated("Use currentUserProvider instead") override val currentUser: Maybe get() { - if (_currentUser == null) { - // immediately get a result synchronously - _currentUser = runBlocking { userManager.getCurrentUser() } - if (currentUserObserver == null) { - currentUserObserver = scope.launch { - userManager.currentUserFlow.filterNotNull().collect { - _currentUser = it - } - } - } - } - return _currentUser?.let { Maybe.just(it) } ?: Maybe.empty() + val user = _currentUser ?: runBlocking { userManager.getCurrentUser() }?.also { _currentUser = it } + return user?.let { Maybe.just(it) } ?: Maybe.empty() } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index 9b7da27ae05..a37d5903044 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -233,8 +233,9 @@ class ComposePreviewUtils private constructor(context: Context) { unifiedSearchRepository = unifiedSearchRepository, mediaRecorderManager = mediaRecorderManager, audioFocusRequestManager = audioFocusRequestManager, - currentUserProvider = currentUserProvider, + userManager = userManager, appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + internalUserId = 1L, chatRoomToken = "", conversationThreadId = null, logger = TestLogger diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 1dc27b7788a..27ed5511b49 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -205,6 +205,10 @@ class DummyUserDaoImpl : UsersDao { override suspend fun getUserWithId(id: Long): UserEntity? = dummyUsers.find { it.id == id } + override fun getUserWithIdFlow(id: Long): Flow = flowOf(dummyUsers.find { it.id == id }) + + override suspend fun getActiveUsersCount(): Int = if (activeUserId != null) 1 else 0 + override suspend fun getUserWithIdNotScheduledForDeletion(id: Long): UserEntity? = dummyUsers.find { it.id == id && !it.scheduledForDeletion } diff --git a/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt b/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt index 60f879e5053..c61b5c1d78e 100644 --- a/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt @@ -15,19 +15,16 @@ import com.nextcloud.talk.models.domain.StartCallRecordingModel import com.nextcloud.talk.models.domain.StopCallRecordingModel import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class CallRecordingViewModel @Inject constructor( - private val repository: CallRecordingRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class CallRecordingViewModel @Inject constructor(private val repository: CallRecordingRepository) : ViewModel() { lateinit var roomToken: String + private lateinit var currentUser: User sealed interface ViewState open class RecordingStartedState(val hasVideo: Boolean, val showStartedInfo: Boolean) : ViewState @@ -44,8 +41,8 @@ class CallRecordingViewModel @Inject constructor( private var disposable: Disposable? = null - private var currentUser: User = currentUserProvider.currentUser.blockingGet() - val credentials: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! + private val credentials: String + get() = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! fun clickRecordButton() { when (viewState.value) { @@ -117,7 +114,8 @@ class CallRecordingViewModel @Inject constructor( disposable?.dispose() } - fun setData(roomToken: String) { + fun setData(user: User, roomToken: String) { + this.currentUser = user this.roomToken = roomToken } diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt new file mode 100644 index 00000000000..634b6289270 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user + +import com.nextcloud.talk.data.user.model.UserEntity +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +class UsersRepositoryImplTest { + + private val usersDao: UsersDao = mock() + private val repository = UsersRepositoryImpl(usersDao) + + @Test + fun `getActiveUser only reads the database`() = + runTest { + val entity = UserEntity(id = 1L, username = "userA", baseUrl = "https://example.com", current = true) + wheneverBlocking { usersDao.getActiveUser() }.thenReturn(entity) + + val result = repository.getActiveUser() + + assertEquals(1L, result?.id) + verifyBlocking(usersDao, never()) { setUserAsActiveWithId(any()) } + } +} diff --git a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt index 448edbdaf3b..59978b79a40 100644 --- a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt @@ -10,6 +10,8 @@ import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.ExternalSignalingServer import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -55,6 +57,52 @@ class UserManagerTest { verifyBlocking(usersRepository, never()) { getUsersNotScheduledForDeletion() } } + @Test + fun `getCurrentUser does not write to the database when an active user exists`() = + runTest { + val active = user(id = 1, username = "userA", baseUrl = "https://example.com", current = true) + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) + + userManager.getCurrentUser() + + verifyBlocking(usersRepository, never()) { setUserAsActiveWithId(any()) } + } + + @Test + fun `userFlow emits the user with the given id and skips unchanged rows`() = + runTest { + val userA = user(id = 1, username = "userA", baseUrl = "https://example.com") + val renamedUserA = userA.copy(displayName = "New Name") + wheneverBlocking { usersRepository.getUserWithIdFlow(1L) } + .thenReturn(flowOf(userA, userA.copy(), renamedUserA, null)) + + val emissions = userManager.userFlow(1L).toList() + + assertEquals(listOf(userA, renamedUserA, null), emissions) + } + + @Test + fun `repairMultipleActiveUsers keeps only the resolved active user when several are active`() = + runTest { + val active = user(id = 2, username = "userB", baseUrl = "https://example.com", current = true) + wheneverBlocking { usersRepository.getActiveUsersCount() }.thenReturn(2) + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) + + userManager.repairMultipleActiveUsers() + + verifyBlocking(usersRepository) { setUserAsActiveWithId(2L) } + } + + @Test + fun `repairMultipleActiveUsers does not write when at most one user is active`() = + runTest { + wheneverBlocking { usersRepository.getActiveUsersCount() }.thenReturn(1) + + userManager.repairMultipleActiveUsers() + + verifyBlocking(usersRepository, never()) { setUserAsActiveWithId(any()) } + } + @Test fun `getCurrentUser falls back to any non-deleted user and sets it active when none is active`() = runTest { @@ -243,6 +291,19 @@ class UserManagerTest { assertEquals(target, userManager.currentUserFlow.value) } + @Test + fun `setUserAsActive publishes the stored row instead of the passed user`() = + runTest { + val passed = user(id = 1, username = "userA", baseUrl = "https://example.com") + val stored = passed.copy(current = true, displayName = "Stored Name") + wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(true) + wheneverBlocking { usersRepository.getUserWithId(1L) }.thenReturn(stored) + + userManager.setUserAsActive(passed) + + assertEquals(stored, userManager.currentUserFlow.value) + } + @Test fun `setUserAsActive leaves currentUserFlow untouched when it fails`() = runTest { diff --git a/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt index 7efd5c9a15f..e619c8ebf4f 100644 --- a/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt @@ -6,14 +6,8 @@ */ package com.nextcloud.talk.viewmodels -import com.nextcloud.talk.data.user.UsersDao -import com.nextcloud.talk.data.user.UsersRepository -import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.test.fakes.FakeCallRecordingRepository -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl -import com.nextcloud.talk.utils.preview.DummyUserDaoImpl import com.vividsolutions.jts.util.Assert import org.junit.Before import org.junit.Test @@ -23,17 +17,7 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { private val repository = FakeCallRecordingRepository() - val usersDao: UsersDao - get() = DummyUserDaoImpl() - - val userRepository: UsersRepository - get() = UsersRepositoryImpl(usersDao) - - val userManager: UserManager - get() = UserManager(userRepository) - - val userProvider: CurrentUserProviderOld - get() = CurrentUserProviderOldImpl(userManager) + private val user = User(id = 1L, username = "user1", baseUrl = "https://server1", token = "token") @Before fun setUp() { @@ -42,8 +26,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_clickStartRecord() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.clickRecordButton() Assert.isTrue(viewModel.viewState.value is CallRecordingViewModel.RecordingStartingState) @@ -56,8 +40,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_clickStopRecord() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE) Assert.equals(true, (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo) @@ -73,8 +57,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_keepConfirmState() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE) Assert.equals(true, (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo) @@ -90,8 +74,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_continueRecordingWhenDismissStopDialog() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE) viewModel.clickRecordButton() @@ -109,8 +93,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testSetRecordingStateDirectly() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STOPPED_CODE) Assert.isTrue(viewModel.viewState.value is CallRecordingViewModel.RecordingStoppedState) From a2cfadc33d3e354622692ef3c0246bd04f142b9f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 17:54:53 +0200 Subject: [PATCH 02/76] fix(user): bind account switching and background work to an account - Switching accounts opens the conversation list of the new account in a cleared task, so no screen of the previous account stays in the back stack (account dialog, SwitchAccountActivity, ecosystem account handoff) - ConversationsListActivity keeps its account across new intents and opens a fresh instance for another account - Notification receivers require the user id instead of falling back to the active account; the recording actions now pass it - LeaveConversationWorker, UploadAndShareFilesWorker and DownloadFileToCacheWorker use the user id from their input data Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/account/SwitchAccountActivity.kt | 21 +++++++---- .../com/nextcloud/talk/chat/ChatActivity.kt | 5 +-- .../talk/chat/viewmodels/ChatViewModel.kt | 1 + .../ChooseAccountDialogCompose.kt | 6 ++-- .../ConversationsListActivity.kt | 35 +++++++++++++------ .../talk/jobs/DownloadFileToCacheWorker.kt | 16 +++------ .../talk/jobs/LeaveConversationWorker.kt | 8 ++--- .../nextcloud/talk/jobs/NotificationWorker.kt | 2 ++ .../talk/jobs/UploadAndShareFilesWorker.kt | 11 +++--- .../viewmodels/MediaViewerViewModel.kt | 4 +-- .../talk/receivers/DirectReplyReceiver.kt | 11 +++--- .../DismissRecordingAvailableReceiver.kt | 11 +++--- .../talk/receivers/MarkAsReadReceiver.kt | 11 +++--- .../receivers/ShareRecordingToChatReceiver.kt | 11 +++--- .../nextcloud/talk/utils/FileViewerUtils.kt | 4 +-- 15 files changed, 84 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt index 3198b3b1797..e3a198dfde3 100644 --- a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySwitchAccountBinding import com.nextcloud.talk.models.ImportAccount import com.nextcloud.talk.models.json.participants.ParticipantDto +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.DirectShareHelper import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.AccountUtils.findAvailableAccountsOnDevice @@ -86,6 +87,18 @@ class SwitchAccountActivity : BaseActivity() { supportActionBar?.title = resources!!.getString(R.string.nc_select_an_account) } + private fun switchToAccount(user: User) { + lifecycleScope.launch { + if (userManager.setUserAsActive(user)) { + DirectShareHelper.removeAllShareTargetShortcuts(this@SwitchAccountActivity) + cookieManager.cookieStore.removeAll() + val intent = ConversationsListActivity.createAccountSwitchIntent(this@SwitchAccountActivity, user.id!!) + startActivity(intent) + finish() + } + } + } + @Suppress("Detekt.NestedBlockDepth") override fun onResume() { super.onResume() @@ -95,13 +108,7 @@ class SwitchAccountActivity : BaseActivity() { if (isAccountImport) { reauthorizeFromImport(item.account) } else { - lifecycleScope.launch { - if (userManager.setUserAsActive(item.user!!)) { - DirectShareHelper.removeAllShareTargetShortcuts(this@SwitchAccountActivity) - cookieManager.cookieStore.removeAll() - finish() - } - } + switchToAccount(item.user!!) } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 79de978b140..155f1d55c28 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2416,8 +2416,6 @@ class ChatActivity : chatViewModel.syncVoiceMessageUiState(message) message.openWhenDownloaded = openWhenDownloaded - val baseUrl = conversationUser.baseUrl - val userId = conversationUser.userId val attachmentFolder = CapabilitiesUtil.getAttachmentFolder( conversationUser.capabilities!! .spreedCapability!! @@ -2446,8 +2444,7 @@ class ChatActivity : } val data: Data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId) + .putLong(KEY_INTERNAL_USER_ID, conversationUserId) .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index deb68feb827..c72e120f803 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2376,6 +2376,7 @@ class ChatViewModel @AssistedInject constructor( val internalConversationId = "${currentUser.id}@$chatRoomToken" val workerId = UploadAndShareFilesWorker.upload( + userId = internalUserId, fileUri = fileUri, roomToken = room, conversationName = displayName, diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index a162e45b873..6ba6cbec76a 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -299,9 +299,9 @@ class ChooseAccountDialogCompose { scope.launch { if (userManager.setUserAsActive(userItem.user)) { cookieManager.cookieStore.removeAll() - val intent = Intent(activity, ConversationsListActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - activity.startActivity(intent) + activity.startActivity( + ConversationsListActivity.createAccountSwitchIntent(activity, userItem.user.id!!) + ) onSelected() } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index e5443d47d61..bcd846c0490 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -11,6 +11,7 @@ import android.annotation.SuppressLint import android.app.NotificationManager import android.content.ActivityNotFoundException import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -198,12 +199,8 @@ class ConversationsListActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) ecosystemManager = EcosystemManager(this@ConversationsListActivity) - val targetUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) - currentUser = if (targetUserId != 0L) { - runBlocking { userManager.getUserWithId(targetUserId) }!! - } else { - currentUserProviderOld.currentUser.blockingGet() - } + currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + ?: currentUserProviderOld.currentUser.blockingGet() conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] @@ -237,6 +234,15 @@ class ConversationsListActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + if (newUserId != 0L && newUserId != currentUser?.id) { + // The list is bound to one account, so open a fresh instance for the other one. + intent.removeFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + finish() + startActivity(intent) + return + } + currentUser?.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } setIntent(intent) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) @@ -350,10 +356,8 @@ class ConversationsListActivity : BaseActivity() { val user = users.firstOrNull { user -> user.username == trimmedAccountName && baseUrl == user.baseUrl?.toUri()?.host } - if (user != null) { - userManager.setUserAsActive(user) - val intent = Intent(context, ConversationsListActivity::class.java) - startActivity(intent) + if (user != null && userManager.setUserAsActive(user)) { + startActivity(createAccountSwitchIntent(context, user.id!!)) } else { showSnackbar(getString(R.string.nc_no_account_found)) } @@ -1566,6 +1570,17 @@ class ConversationsListActivity : BaseActivity() { companion object { private val TAG = ConversationsListActivity::class.java.simpleName + + /** + * Creates an intent that shows the conversation list of the account with the internal id [userId] in a + * cleared task, so no screen of a previously shown account stays in the back stack. + */ + fun createAccountSwitchIntent(context: Context, userId: Long): Intent = + Intent(context, ConversationsListActivity::class.java).apply { + putExtra(KEY_INTERNAL_USER_ID, userId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + const val BOTTOM_SHEET_DELAY: Long = 2500 const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 const val HTTP_UNAUTHORIZED = 401 diff --git a/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt index db0f9039f21..2c9f00d9cd7 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt @@ -19,8 +19,9 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.preferences.AppPreferences +import kotlinx.coroutines.runBlocking import okhttp3.ResponseBody import java.io.BufferedInputStream import java.io.File @@ -41,9 +42,6 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var appPreferences: AppPreferences @@ -55,9 +53,7 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa } try { - val currentUser = currentUserProvider.currentUser.blockingGet() - val baseUrl = inputData.getString(KEY_BASE_URL) - val userId = inputData.getString(KEY_USER_ID) + val currentUser = runBlocking { userManager.getUserWithId(inputData.getLong(KEY_INTERNAL_USER_ID, 0L)) } val attachmentFolder = inputData.getString(KEY_ATTACHMENT_FOLDER) val fileId = inputData.getString(KEY_FILE_ID) val fileName = inputData.getString(KEY_FILE_NAME) @@ -65,8 +61,8 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa totalFileSize = (inputData.getLong(KEY_FILE_SIZE, -1)) checkNotNull(currentUser) - checkNotNull(baseUrl) - checkNotNull(userId) + val baseUrl = checkNotNull(currentUser.baseUrl) + val userId = checkNotNull(currentUser.userId) checkNotNull(attachmentFolder) checkNotNull(fileName) checkNotNull(remotePath) @@ -147,8 +143,6 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa companion object { const val TAG = "DownloadFileToCache" - const val KEY_BASE_URL = "KEY_BASE_URL" - const val KEY_USER_ID = "KEY_USER_ID" const val KEY_ATTACHMENT_FOLDER = "KEY_ATTACHMENT_FOLDER" const val KEY_FILE_ID = "KEY_FILE_ID" const val KEY_FILE_NAME = "KEY_FILE_NAME" diff --git a/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt index da17ac617fd..ab512b8409d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt @@ -26,11 +26,11 @@ import com.nextcloud.talk.utils.ApiUtils.getConversationApiVersion import com.nextcloud.talk.utils.ApiUtils.getCredentials import com.nextcloud.talk.utils.ApiUtils.getUrlForParticipantsSelf import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking import retrofit2.HttpException import javax.inject.Inject @@ -45,15 +45,13 @@ class LeaveConversationWorker(context: Context, workerParams: WorkerParameters) @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - private val result = SettableFuture.create() override fun startWork(): ListenableFuture { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) val conversationToken = inputData.getString(BundleKeys.KEY_ROOM_TOKEN) - val currentUser = currentUserProvider.currentUser.blockingGet() + val userId = inputData.getLong(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + val currentUser = runBlocking { userManager.getUserWithId(userId) } if (currentUser != null && conversationToken != null) { val credentials = getCredentials(currentUser.username, currentUser.token) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index b29a415f96b..6b2042243e5 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -1062,6 +1062,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val dismissIntent = Intent(context, DismissRecordingAvailableReceiver::class.java) dismissIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId) + dismissIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) dismissIntent.putExtra(KEY_DISMISS_RECORDING_URL, dismissRecordingUrl) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -1095,6 +1096,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val shareRecordingIntent = Intent(context, ShareRecordingToChatReceiver::class.java) shareRecordingIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId) + shareRecordingIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) shareRecordingIntent.putExtra(KEY_SHARE_RECORDING_TO_CHAT_URL, shareToChatUrl) shareRecordingIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index 1725c70c194..f255a941b4e 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -50,7 +50,7 @@ import com.nextcloud.talk.utils.ImageCompressor import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.RemoteFileUtils import com.nextcloud.talk.utils.VideoCompressor -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable @@ -85,9 +85,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa @Inject lateinit var ncApiCoroutines: NcApiCoroutines - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var appPreferences: AppPreferences @@ -142,7 +139,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) private fun doUpload(): Result { return try { - currentUser = currentUserProvider.currentUser.blockingGet() + currentUser = checkNotNull( + runBlocking { userManager.getUserWithId(inputData.getLong(KEY_INTERNAL_USER_ID, 0L)) } + ) val sourceFile = inputData.getString(DEVICE_SOURCE_FILE) roomToken = inputData.getString(ROOM_TOKEN)!! conversationName = inputData.getString(CONVERSATION_NAME)!! @@ -527,6 +526,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa @Suppress("LongParameterList") fun upload( + userId: Long, fileUri: String, roomToken: String, conversationName: String, @@ -537,6 +537,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa allowUpdate: Boolean = false ): UUID { val data: Data = Data.Builder() + .putLong(KEY_INTERNAL_USER_ID, userId) .putString(DEVICE_SOURCE_FILE, fileUri) .putString(ROOM_TOKEN, roomToken) .putString(CONVERSATION_NAME, conversationName) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt index 27563729fe6..930e4afac0a 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -137,8 +138,7 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository _uiState.update { it.copy(downloadingMessageIds = it.downloadingMessageIds + item.messageId) } val data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, user.baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, user.userId) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) .putString( DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, CapabilitiesUtil.getAttachmentFolder(user.capabilities!!.spreedCapability!!) diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt index 42873ec6478..fca8cacb07b 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt @@ -34,7 +34,6 @@ import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.Observer import io.reactivex.Single @@ -50,9 +49,6 @@ class DirectReplyReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -74,8 +70,11 @@ class DirectReplyReceiver : BroadcastReceiver() { systemNotificationId = intent!!.getIntExtra(KEY_SYSTEM_NOTIFICATION_ID, 0) roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } replyMessage = getMessageText(intent) sendDirectReply() diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt index e9fba339aff..cdfa1610822 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt @@ -21,7 +21,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -35,9 +34,6 @@ class DismissRecordingAvailableReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -58,8 +54,11 @@ class DismissRecordingAvailableReceiver : BroadcastReceiver() { systemNotificationId = intent!!.getIntExtra(KEY_SYSTEM_NOTIFICATION_ID, 0) link = intent.getStringExtra(BundleKeys.KEY_DISMISS_RECORDING_URL) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } dismissNcRecordingAvailableNotification() } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt index fdaa213d52f..f83da396e6e 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt @@ -23,7 +23,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MESSAGE_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -37,9 +36,6 @@ class MarkAsReadReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -62,8 +58,11 @@ class MarkAsReadReceiver : BroadcastReceiver() { roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) messageId = intent.getIntExtra(KEY_MESSAGE_ID, 0) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } markAsRead() } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt index 1c48a6cad8b..9ec1630ee96 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt @@ -24,7 +24,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -38,9 +37,6 @@ class ShareRecordingToChatReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -58,8 +54,11 @@ class ShareRecordingToChatReceiver : BroadcastReceiver() { systemNotificationId = intent!!.getIntExtra(KEY_SYSTEM_NOTIFICATION_ID, 0) link = intent.getStringExtra(BundleKeys.KEY_SHARE_RECORDING_TO_CHAT_URL) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } shareRecordingToChat() } diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index fbbf8f969ef..513da64c206 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -47,6 +47,7 @@ import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX import com.nextcloud.talk.utils.Mimetype.VIDEO_QUICKTIME import com.nextcloud.talk.utils.Mimetype.VIDEO_WEBM import com.nextcloud.talk.utils.MimetypeUtils.isMarkdown +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID import java.util.concurrent.ExecutionException @@ -319,8 +320,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } val data: Data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, user.baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, user.userId) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) .putString( DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, CapabilitiesUtil.getAttachmentFolder(user.capabilities!!.spreedCapability!!) From d323f26035d38fbd867e24cefa41c53fa13b93f7 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 17:59:46 +0200 Subject: [PATCH 03/76] fix(user): bind conversation list screens to their account ConversationsListViewModel, ConversationTagsViewModel and ThreadsOverviewViewModel get the user of their screen via assisted injection instead of reading the active account at construction time. FilterConversationFragment receives the user as argument. Opening the conversation list of an account makes it the last used (default) account, which the account switcher and status views use. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 30 ++++++++++++++++--- .../viewmodels/ConversationsListViewModel.kt | 24 ++++++++------- .../viewmodels/ConversationTagsViewModel.kt | 17 +++++++---- .../talk/dagger/modules/ViewModelModule.kt | 18 ----------- .../ThreadsOverviewActivity.kt | 15 ++++++++-- .../viewmodels/ThreadsOverviewViewModel.kt | 22 ++++++++------ .../ui/dialog/FilterConversationFragment.kt | 16 +++++----- 7 files changed, 85 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index bcd846c0490..49e77a5e199 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -141,7 +142,10 @@ class ConversationsListActivity : BaseActivity() { lateinit var platformPermissionUtil: PlatformPermissionUtil @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var conversationsListViewModelFactory: ConversationsListViewModel.Factory + + @Inject + lateinit var conversationTagsViewModelFactory: ConversationTagsViewModel.Factory @Inject lateinit var networkMonitor: NetworkMonitor @@ -201,9 +205,24 @@ class ConversationsListActivity : BaseActivity() { currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: currentUserProviderOld.currentUser.blockingGet() + currentUser?.takeIf { !it.current }?.let { user -> + // The shown account becomes the last used one, which the account switcher and status views rely on. + lifecycleScope.launch { userManager.setUserAsActive(user) } + } - conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] - conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] + val user = currentUser!! + conversationsListViewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(ConversationsListViewModel::class.java) { + conversationsListViewModelFactory.build(user) + } + )[ConversationsListViewModel::class.java] + conversationTagsViewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(ConversationTagsViewModel::class.java) { + conversationTagsViewModelFactory.build(user) + } + )[ConversationTagsViewModel::class.java] setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) @@ -321,7 +340,10 @@ class ConversationsListActivity : BaseActivity() { onFederationHintClick = { startActivity(Intent(context, InvitationsActivity::class.java)) }, onFilterClick = { FilterConversationFragment - .newInstance(conversationsListViewModel.filterStateFlow.value.toMutableMap()) + .newInstance( + conversationsListViewModel.filterStateFlow.value.toMutableMap(), + conversationsListViewModel.currentUser + ) .show(supportFragmentManager, FilterConversationFragment.TAG) }, onThreadsClick = { openFollowedThreadsOverview() }, diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index ecb3ab613d3..a46d310eabf 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -42,8 +42,10 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.withRetry +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -66,13 +68,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") -class ConversationsListViewModel @Inject constructor( +class ConversationsListViewModel @AssistedInject constructor( private val repository: OfflineConversationsRepository, private val threadsRepository: ThreadsRepository, - private val currentUserProvider: CurrentUserProviderOld, private val openConversationsRepository: OpenConversationsRepository, private val contactsRepository: ContactsRepository, private val unifiedSearchRepository: UnifiedSearchRepository, @@ -81,12 +81,11 @@ class ConversationsListViewModel @Inject constructor( var userManager: UserManager, private val conversationsRepository: ConversationsRepository, private val conversationListUpdater: ConversationListUpdater, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { - private val _currentUser = currentUserProvider.currentUser.blockingGet() - val currentUser: User = _currentUser - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val searchHelper = MessageSearchHelper(unifiedSearchRepository, currentUser) @@ -576,7 +575,7 @@ class ConversationsListViewModel @Inject constructor( fun checkIfThreadsExist() { val limitForFollowedThreadsExistenceCheck = 1 - val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet()) + val accountId = UserIdUtils.getIdForUser(currentUser) fun isLastCheckTooOld(lastCheckDate: Long): Boolean { val currentTimeMillis = System.currentTimeMillis() @@ -944,8 +943,6 @@ class ConversationsListViewModel @Inject constructor( } override fun onNext(invitationsModel: InvitationsModel) { - val currentUser = currentUserProvider.currentUser.blockingGet() - if (invitationsModel.user.userId?.equals(currentUser.userId) == true && invitationsModel.user.baseUrl?.equals(currentUser.baseUrl) == true ) { @@ -966,6 +963,11 @@ class ConversationsListViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): ConversationsListViewModel + } + companion object { private val TAG = ConversationsListViewModel::class.java.simpleName const val FOLLOWED_THREADS_EXIST_LAST_CHECK = "FOLLOWED_THREADS_EXIST_LAST_CHECK" diff --git a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt index 7077f74f5f1..c5dbff4ce17 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt @@ -22,7 +22,9 @@ import com.nextcloud.talk.models.json.tags.ConversationTagErrorOverall import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -30,18 +32,16 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import retrofit2.HttpException -import javax.inject.Inject @Suppress("TooManyFunctions") -class ConversationTagsViewModel @Inject constructor( +class ConversationTagsViewModel @AssistedInject constructor( private val conversationTagsRepository: ConversationTagsRepository, private val repository: OfflineConversationsRepository, - private val currentUserProvider: CurrentUserProviderOld, private val conversationListUpdater: ConversationListUpdater, - private val logger: Logger + private val logger: Logger, + @Assisted private val currentUser: User ) : ViewModel() { - private val currentUser: User = currentUserProvider.currentUser.blockingGet() private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val _conversationTagsFlow = MutableStateFlow>(emptyList()) @@ -218,6 +218,11 @@ class ConversationTagsViewModel @Inject constructor( private fun List?.toDisplayTags(): List = this?.filter { it.type != ConversationTagDto.TYPE_OTHER }?.sortedBy { it.sortOrder } ?: emptyList() + @AssistedFactory + interface Factory { + fun build(user: User): ConversationTagsViewModel + } + companion object { private val TAG = ConversationTagsViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 421eb093b1b..9e34d663a22 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -20,8 +20,6 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel -import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel -import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.diagnosis.DiagnosisViewModel import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel @@ -36,7 +34,6 @@ import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel -import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel import com.nextcloud.talk.translate.viewmodels.TranslateViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel @@ -139,16 +136,6 @@ abstract class ViewModelModule { @ViewModelKey(OpenConversationsViewModel::class) abstract fun openConversationsViewModel(viewModel: OpenConversationsViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ConversationsListViewModel::class) - abstract fun conversationsListViewModel(viewModel: ConversationsListViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ConversationTagsViewModel::class) - abstract fun conversationTagsViewModel(viewModel: ConversationTagsViewModel): ViewModel - // @Binds // @IntoMap // @ViewModelKey(ChatViewModel::class) @@ -189,11 +176,6 @@ abstract class ViewModelModule { @ViewModelKey(LogsViewModel::class) abstract fun logsViewModel(viewModel: LogsViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ThreadsOverviewViewModel::class) - abstract fun threadsOverviewViewModel(viewModel: ThreadsOverviewViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(BrowserLoginActivityViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index 7654190e416..ca70c785e1d 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.threadsoverview import android.os.Bundle import android.text.format.DateUtils +import android.util.Log import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -44,6 +45,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.models.json.threads.ThreadInfoDto import com.nextcloud.talk.threadsoverview.components.ThreadRow @@ -51,12 +53,13 @@ import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import javax.inject.Inject +import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class ThreadsOverviewActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var threadsOverviewViewModelFactory: ThreadsOverviewViewModel.Factory @Inject lateinit var ncApi: NcApi @@ -72,9 +75,17 @@ class ThreadsOverviewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + Log.e(TAG, "No user found") + finish() + return + } threadsOverviewViewModel = ViewModelProvider( this, - viewModelFactory + ViewModelFactoryWithParams(ThreadsOverviewViewModel::class.java) { + threadsOverviewViewModelFactory.build(user) + } )[ThreadsOverviewViewModel::class.java] val colorScheme = viewThemeUtils.getColorScheme(this) diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt index 0bc6c1bc506..c45a7fe9e09 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt @@ -19,22 +19,21 @@ import com.nextcloud.talk.models.json.threads.ThreadsOverall import com.nextcloud.talk.threadsoverview.data.ThreadsRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.UserIdUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("TooGenericExceptionCaught") -class ThreadsOverviewViewModel @Inject constructor( +class ThreadsOverviewViewModel @AssistedInject constructor( private val threadsRepository: ThreadsRepository, - private val currentUserProvider: CurrentUserProviderOld, private val arbitraryStorageManager: ArbitraryStorageManager, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { - private val _currentUser = currentUserProvider.currentUser.blockingGet() - val currentUser: User = _currentUser - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val _threadsListState = MutableStateFlow(ThreadsListUiState.None) val threadsListState: StateFlow = _threadsListState @@ -59,7 +58,7 @@ class ThreadsOverviewViewModel @Inject constructor( private fun updateFollowedThreadsIndicator(url: String, threads: ThreadsOverall) { val subscribedThreadsEndpoint = "subscribed-threads" if (url.contains(subscribedThreadsEndpoint) && threads.ocs?.data?.isEmpty() == true) { - val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet()) + val accountId = UserIdUtils.getIdForUser(currentUser) arbitraryStorageManager.storeStorageSetting( accountId, FOLLOWED_THREADS_EXIST, @@ -81,6 +80,11 @@ class ThreadsOverviewViewModel @Inject constructor( data class Error(val exception: Exception) : ThreadsListUiState() } + @AssistedFactory + interface Factory { + fun build(user: User): ThreadsOverviewViewModel + } + companion object { private val TAG = ThreadsOverviewViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt index aa6fc1245de..0f26daf657b 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt @@ -13,6 +13,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -20,12 +21,12 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogFilterConversationBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -33,9 +34,7 @@ class FilterConversationFragment : DialogFragment() { lateinit var binding: DialogFilterConversationBinding private var dialogView: View? = null private lateinit var filterState: HashMap - - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + private lateinit var user: User @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -50,6 +49,7 @@ class FilterConversationFragment : DialogFragment() { } else { arguments?.getSerializable(FILTER_STATE_ARG) as HashMap } + user = BundleCompat.getParcelable(requireArguments(), USER_ARG, User::class.java)!! return MaterialAlertDialogBuilder(requireContext()).setView(dialogView).create() } @@ -117,7 +117,7 @@ class FilterConversationFragment : DialogFragment() { binding.mentionedFilterChip.isChecked = filterState[MENTION]!! binding.archivedFilterChip.visibility = View.GONE - currentUserProvider.currentUser.blockingGet().capabilities?.spreedCapability?.let { + user.capabilities?.spreedCapability?.let { if (hasSpreedFeatureCapability(it, SpreedFeatures.ARCHIVE_CONVERSATIONS)) { binding.archivedFilterChip.visibility = View.VISIBLE binding.archivedFilterChip.isChecked = filterState[ARCHIVE]!! @@ -127,7 +127,7 @@ class FilterConversationFragment : DialogFragment() { private fun processSubmit() { // store - val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet()) + val accountId = UserIdUtils.getIdForUser(user) val mentionValue = filterState[MENTION] == true val unreadValue = filterState[UNREAD] == true val archivedValue = filterState[ARCHIVE] == true @@ -153,12 +153,14 @@ class FilterConversationFragment : DialogFragment() { companion object { private const val FILTER_STATE_ARG = "FILTER_STATE_ARG" + private const val USER_ARG = "USER_ARG" @JvmStatic - fun newInstance(savedFilterState: MutableMap): FilterConversationFragment { + fun newInstance(savedFilterState: MutableMap, user: User): FilterConversationFragment { val filterConversationFragment = FilterConversationFragment() val args = Bundle() args.putSerializable(FILTER_STATE_ARG, HashMap(savedFilterState)) + args.putParcelable(USER_ARG, user) filterConversationFragment.arguments = args return filterConversationFragment } From b5d33ab02efd8a14741fcc9c1006493f784dd313 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:02:27 +0200 Subject: [PATCH 04/76] fix(user): bind conversation info screens to their account ConversationInfoActivity and ConversationInfoEditActivity use the user id they were started with, and pass it on to the screens they open. ConversationInfoEditViewModel and DialogBanListFragment get the user from their screen. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../conversationinfo/ConversationInfoActivity.kt | 12 ++++++++++-- .../ConversationInfoEditActivity.kt | 13 ++++++++++++- .../viewmodel/ConversationInfoEditViewModel.kt | 10 ++++------ .../talk/ui/dialog/DialogBanListFragment.kt | 10 ++-------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index aa5c4fe0e27..f168a3cf0c8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -125,6 +126,9 @@ class ConversationInfoActivity : BaseActivity() { @Inject lateinit var dateUtils: DateUtils + @Inject + lateinit var userManager: UserManager + lateinit var viewModel: ConversationInfoViewModel private lateinit var conversationToken: String @@ -194,7 +198,7 @@ class ConversationInfoActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] lifecycleScope.launch { - currentUserProvider.getCurrentUser() + runCatching { checkNotNull(userManager.getUserWithId(resolveUserIdFromIntent())) { "User not found" } } .onSuccess { user -> conversationUser = user credentials = ApiUtils.getCredentials(user.username, user.token)!! @@ -320,6 +324,7 @@ class ConversationInfoActivity : BaseActivity() { onEditConversation = { editConversationResult.launch( Intent(this, ConversationInfoEditActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser?.id) putExtra(KEY_ROOM_TOKEN, conversationToken) } ) @@ -404,6 +409,7 @@ class ConversationInfoActivity : BaseActivity() { val conv = viewModel.uiState.value.conversation ?: return startActivity( Intent(this, SharedItemsActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser?.id) putExtra(BundleKeys.KEY_CONVERSATION_NAME, conv.displayName) putExtra(KEY_ROOM_TOKEN, conversationToken) putExtra( @@ -511,9 +517,10 @@ class ConversationInfoActivity : BaseActivity() { } private fun listBans() { + val user = conversationUser ?: return val transaction = supportFragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - transaction.add(android.R.id.content, DialogBanListFragment(conversationToken)) + transaction.add(android.R.id.content, DialogBanListFragment(conversationToken, user)) .addToBackStack(null) .commit() } @@ -539,6 +546,7 @@ class ConversationInfoActivity : BaseActivity() { } addParticipantsResult.launch( Intent(this, ContactsActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser?.id) putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) putParcelableArrayListExtra("selectedParticipants", existingParticipants) putExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, true) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index 529728d8904..a1d79d136c4 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -30,12 +30,14 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditCallbacks import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditScreen import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class ConversationInfoEditActivity : BaseActivity() { @@ -43,6 +45,9 @@ class ConversationInfoEditActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var userManager: UserManager + lateinit var conversationInfoEditViewModel: ConversationInfoEditViewModel private var pickImage: PickImage? = null @@ -77,11 +82,17 @@ class ConversationInfoEditActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) val roomToken = intent.extras?.getString(BundleKeys.KEY_ROOM_TOKEN)!! + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + Log.e(TAG, "No user found") + finish() + return + } conversationInfoEditViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] - conversationInfoEditViewModel.initialize(roomToken) + conversationInfoEditViewModel.initialize(user, roomToken) conversationInfoEditViewModel.uiState.value.conversationUser?.let { user -> pickImage = PickImage(this, user) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt index 1f17dc70d46..46162f3ae9d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt @@ -16,7 +16,6 @@ import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,7 +26,6 @@ import javax.inject.Inject class ConversationInfoEditViewModel @Inject constructor( private val conversationInfoEditRepository: ConversationInfoEditRepository, - private val currentUserProvider: CurrentUserProvider, private val logger: Logger ) : ViewModel() { @@ -38,9 +36,10 @@ class ConversationInfoEditViewModel @Inject constructor( private var initialized = false private var currentUser: User? = null - fun initialize(token: String) { + fun initialize(user: User, token: String) { if (initialized) return initialized = true + currentUser = user roomToken = token loadRoom() } @@ -66,8 +65,7 @@ class ConversationInfoEditViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { try { - val user = currentUserProvider.getCurrentUser().getOrThrow() - currentUser = user + val user = checkNotNull(currentUser) val roomData = conversationInfoEditRepository.getRoom(user, roomToken) val conversationModel = roomData.conversation val isEvent = conversationModel.objectType == ConversationEnums.ObjectType.EVENT @@ -181,7 +179,7 @@ class ConversationInfoEditViewModel @Inject constructor( fun renameRoom(token: String, newName: String) { viewModelScope.launch { try { - val user = currentUser ?: currentUserProvider.getCurrentUser().getOrThrow() + val user = currentUser ?: return@launch conversationInfoEditRepository.renameConversation(user, token, newName) _uiState.update { it.copy(navigateBack = true) } } catch (exception: Exception) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt index 6647a03ed4e..31249a3f491 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt @@ -26,11 +26,10 @@ import com.nextcloud.talk.databinding.BanItemListBinding import com.nextcloud.talk.databinding.FragmentDialogBanListBinding import com.nextcloud.talk.models.json.participants.TalkBanDto import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DialogBanListFragment(val roomToken: String) : DialogFragment() { +class DialogBanListFragment(val roomToken: String, private val conversationUser: User) : DialogFragment() { lateinit var binding: FragmentDialogBanListBinding @@ -40,11 +39,7 @@ class DialogBanListFragment(val roomToken: String) : DialogFragment() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - lateinit var viewModel: ConversationInfoViewModel - private lateinit var conversationUser: User private val adapter = object : BaseAdapter() { private var bans: List = mutableListOf() @@ -90,7 +85,6 @@ class DialogBanListFragment(val roomToken: String) : DialogFragment() { binding = FragmentDialogBanListBinding.inflate(layoutInflater) viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] - conversationUser = currentUserProvider.currentUser.blockingGet() themeView() initObservers() @@ -145,7 +139,7 @@ class DialogBanListFragment(val roomToken: String) : DialogFragment() { companion object { @JvmStatic - fun newInstance(roomToken: String) = DialogBanListFragment(roomToken) + fun newInstance(roomToken: String, user: User) = DialogBanListFragment(roomToken, user) const val ONE_SEC = 1000L } } From b15a1e0f0de7a0d113b0bd56448bc6d5579101c2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:05:33 +0200 Subject: [PATCH 05/76] fix(user): bind shared items, media viewer and file browser to their account SharedItemsActivity, MediaViewerActivity and RemoteFileBrowserActivity use the user id they were started with; all launchers pass it. RemoteFileBrowserItemsViewModel gets the user via assisted injection. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 9 +++++- .../talk/dagger/modules/ViewModelModule.kt | 6 ---- .../activities/MediaViewerActivity.kt | 10 ++++++- .../activities/RemoteFileBrowserActivity.kt | 28 +++++++++++++++---- .../RemoteFileBrowserItemsViewModel.kt | 19 +++++++------ .../activities/SharedItemsActivity.kt | 16 +++++++++-- .../adapters/SharedItemsAdapter.kt | 4 ++- .../nextcloud/talk/utils/FileViewerUtils.kt | 4 ++- .../com/nextcloud/talk/utils/PickImage.kt | 1 + 9 files changed, 72 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 155f1d55c28..ea126c9fac5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1339,7 +1339,13 @@ class ChatActivity : if (isViewableMedia && seedItems.any { it.messageId == messageId.toLong() }) { startActivity( - MediaViewerActivity.newIntent(this@ChatActivity, roomToken, seedItems, messageId.toLong()) + MediaViewerActivity.newIntent( + this@ChatActivity, + conversationUserId, + roomToken, + seedItems, + messageId.toLong() + ) ) } else { fileViewerUtils.openFile(chatMessage, openWhenDownloadState, downloadState) @@ -2905,6 +2911,7 @@ class ChatActivity : fun showBrowserScreen() { val sharingFileBrowserIntent = Intent(this, RemoteFileBrowserActivity::class.java) + sharingFileBrowserIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startRemoteFileBrowsingForResult.launch(sharingFileBrowserIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 9e34d663a22..79d7989d1e2 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -32,7 +32,6 @@ import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel -import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel import com.nextcloud.talk.translate.viewmodels.TranslateViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel @@ -106,11 +105,6 @@ abstract class ViewModelModule { @ViewModelKey(PollCreateViewModel::class) abstract fun pollCreateViewModel(viewModel: PollCreateViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(RemoteFileBrowserItemsViewModel::class) - abstract fun remoteFileBrowserItemsViewModel(viewModel: RemoteFileBrowserItemsViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(CallRecordingViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index d0f94f8396a..a7db1d5e483 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -31,12 +31,15 @@ import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import java.io.File import javax.inject.Inject +import kotlinx.coroutines.runBlocking /** * Swipeable, group-aware media viewer - the entry point for every image/video tap in chat. See @@ -48,6 +51,9 @@ class MediaViewerActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var userManager: UserManager + private lateinit var viewModel: MediaViewerViewModel private lateinit var windowInsetsController: WindowInsetsControllerCompat @@ -58,7 +64,7 @@ class MediaViewerActivity : BaseActivity() { val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) val startMessageId = intent.getLongExtra(EXTRA_START_MESSAGE_ID, -1L) - val user = currentUserProviderOld.currentUser.blockingGet() + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } if (roomToken == null || seedItems.isNullOrEmpty() || user == null) { Log.e(TAG, "Missing data to open the media viewer") @@ -135,11 +141,13 @@ class MediaViewerActivity : BaseActivity() { fun newIntent( context: Context, + userId: Long, roomToken: String, seedItems: List, startMessageId: Long ): Intent = Intent(context, MediaViewerActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) putExtra(KEY_ROOM_TOKEN, roomToken) putParcelableArrayListExtra(EXTRA_SEED_ITEMS, ArrayList(seedItems)) putExtra(EXTRA_START_MESSAGE_ID, startMessageId) diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index bff575ea17a..5ba0140aaf7 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -29,18 +29,22 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityRemoteFileBrowserBinding import com.nextcloud.talk.remotefilebrowser.SelectionInterface import com.nextcloud.talk.remotefilebrowser.adapters.RemoteFileBrowserItemsAdapter import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.ui.dialog.SortingOrderDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileSortOrder +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MIME_TYPE_FILTER -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import javax.inject.Inject +import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class RemoteFileBrowserActivity : @@ -49,10 +53,12 @@ class RemoteFileBrowserActivity : SwipeRefreshLayout.OnRefreshListener { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: RemoteFileBrowserItemsViewModel.Factory @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + lateinit var userManager: UserManager + + private lateinit var user: User @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -95,6 +101,15 @@ class RemoteFileBrowserActivity : supportActionBar?.setDisplayHomeAsUpEnabled(true) + val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + user = runBlocking { + if (userId != 0L) userManager.getUserWithId(userId) else userManager.getCurrentUser() + } ?: run { + Log.e(TAG, "No user found") + finish() + return + } + val extras = intent.extras val mimeTypeSelectionFilter = extras?.getString(KEY_MIME_TYPE_FILTER, null) @@ -112,7 +127,10 @@ class RemoteFileBrowserActivity : } private fun initViewModel(mimeTypeSelectionFilter: String?) { - viewModel = ViewModelProvider(this, viewModelFactory)[RemoteFileBrowserItemsViewModel::class.java] + viewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(RemoteFileBrowserItemsViewModel::class.java) { viewModelFactory.build(user) } + )[RemoteFileBrowserItemsViewModel::class.java] viewModel.viewState.observe(this) { state -> clearEmptyLoading() @@ -167,7 +185,7 @@ class RemoteFileBrowserActivity : val adapter = RemoteFileBrowserItemsAdapter( showGrid = showGrid, mimeTypeSelectionFilter = mimeTypeSelectionFilter, - user = currentUserProvider.currentUser.blockingGet(), + user = user, selectionInterface = this, viewThemeUtils = viewThemeUtils, dateUtils = dateUtils, diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt index 7615d7dc744..10cd907c17e 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt @@ -12,12 +12,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.nextcloud.talk.R +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem import com.nextcloud.talk.remotefilebrowser.repositories.RemoteFileBrowserItemsRepository import com.nextcloud.talk.utils.FileSortOrder import com.nextcloud.talk.utils.Mimetype.FOLDER -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preferences.AppPreferencesImpl +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -28,7 +31,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import java.io.File -import javax.inject.Inject /** * @startuml @@ -44,12 +46,10 @@ import javax.inject.Inject * @enduml */ @OptIn(ExperimentalCoroutinesApi::class) -class RemoteFileBrowserItemsViewModel -@Inject -constructor( +class RemoteFileBrowserItemsViewModel @AssistedInject constructor( private val repository: RemoteFileBrowserItemsRepository, private val appPreferences: AppPreferencesImpl, - private val currentUserProvider: CurrentUserProviderOld + @Assisted private val currentUser: User ) : ViewModel() { sealed interface ViewState @@ -59,8 +59,6 @@ constructor( class LoadedState(val items: List) : ViewState class FinishState(val selectedPaths: Set) : ViewState - private val currentUser = currentUserProvider.currentUser.blockingGet() - private val initialSortOrder = FileSortOrder.getFileSortOrder(appPreferences.sorting) private var sortingFlow: Flow @@ -221,6 +219,11 @@ constructor( fun isPathSelected(path: String): Boolean = selectedPaths.value?.contains(path) == true || shouldPathBeSelectedDueToParent(path) + @AssistedFactory + interface Factory { + fun build(user: User): RemoteFileBrowserItemsViewModel + } + companion object { private val TAG = RemoteFileBrowserItemsViewModel::class.simpleName private const val ROOT_PATH = "/" diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index ee54ac4ee2d..4909e455fd4 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -31,11 +31,13 @@ import com.nextcloud.talk.databinding.ActivitySharedItemsBinding import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter import com.nextcloud.talk.shareditems.model.SharedItemType import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import javax.inject.Inject import kotlin.getValue +import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class SharedItemsActivity : BaseActivity() { @@ -46,6 +48,11 @@ class SharedItemsActivity : BaseActivity() { @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory + @Inject + lateinit var userManager: UserManager + + private val conversationUserId: Long by lazy { resolveUserIdFromIntent() } + val roomToken: String by lazy { intent.getStringExtra(KEY_ROOM_TOKEN) ?: error("roomToken missing") @@ -62,7 +69,7 @@ class SharedItemsActivity : BaseActivity() { val chatViewModel: ChatViewModel by viewModels { ViewModelFactoryWithParams(ChatViewModel::class.java) { chatViewModelFactory.build( - currentUserProviderOld.currentUser.blockingGet().id!!, + conversationUserId, roomToken, conversationThreadId ) @@ -80,7 +87,12 @@ class SharedItemsActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) - val user = currentUserProviderOld.currentUser.blockingGet() + val user = runBlocking { userManager.getUserWithId(conversationUserId) } + if (user == null) { + Log.e(TAG, "No user found") + finish() + return + } val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) val isOne2One = intent.getBooleanExtra(KEY_IS_ONE_2_ONE, false) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index 0cad81cf089..210f2dd8ecf 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -124,7 +124,9 @@ class SharedItemsAdapter( .sortedBy { it.messageId } .map { it.toMediaViewerItem() } .capSeedAroundMessage(item.messageId) - context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) + context.startActivity( + MediaViewerActivity.newIntent(context, user.id!!, roomToken, seedItems, item.messageId) + ) } private fun openInChat(item: SharedFileItem, context: Context) { diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 513da64c206..87c79782c26 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -118,7 +118,9 @@ class FileViewerUtils(private val context: Context, private val user: User) { actorDisplayName = message.actorDisplayName.orEmpty(), timestamp = message.timestamp ) - context.startActivity(MediaViewerActivity.newIntent(context, roomToken, listOf(item), item.messageId)) + context.startActivity( + MediaViewerActivity.newIntent(context, user.id!!, roomToken, listOf(item), item.messageId) + ) } fun openFile( diff --git a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt index 3d148e2f39d..a8317671982 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt @@ -82,6 +82,7 @@ class PickImage(private val activity: Activity, private var currentUser: User?) val avatarIntent = Intent(activity, RemoteFileBrowserActivity::class.java) avatarIntent.putExtras(bundle) + currentUser?.id?.let { avatarIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) } startSelectRemoteFilesIntentForResult.launch(avatarIntent) } From 81a48d2fad8795095b58233967a6ee40854cd901 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:08:02 +0200 Subject: [PATCH 06/76] fix(user): bind message search and polls to their account - MessageSearchViewModel gets the user of its screen - The poll dialogs use the user they are created with (the main poll dialog already received it but ignored it) and pass it to their view models Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/chat/ChatActivity.kt | 4 +--- .../talk/messagesearch/MessageSearchActivity.kt | 12 ++++++++++-- .../talk/messagesearch/MessageSearchViewModel.kt | 7 ++----- .../talk/polls/ui/PollCreateDialogFragment.kt | 9 +++++++-- .../talk/polls/ui/PollMainDialogFragment.kt | 7 ++----- .../nextcloud/talk/polls/ui/PollVoteFragment.kt | 2 +- .../talk/polls/viewmodels/PollCreateViewModel.kt | 12 +++++------- .../talk/polls/viewmodels/PollMainViewModel.kt | 16 ++++++---------- .../talk/polls/viewmodels/PollVoteViewModel.kt | 16 +++++----------- 9 files changed, 39 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index ea126c9fac5..5b961d77851 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -4198,9 +4198,7 @@ class ChatActivity : } fun createPoll() { - val pollVoteDialog = PollCreateDialogFragment.newInstance( - roomToken - ) + val pollVoteDialog = PollCreateDialogFragment.newInstance(conversationUser, roomToken) pollVoteDialog.show(supportFragmentManager, TAG) } diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 7b8ac75a34f..2c1922c8fd7 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -28,6 +28,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityMessageSearchBinding +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView import eu.davidea.flexibleadapter.FlexibleAdapter @@ -39,6 +40,7 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import java.util.concurrent.TimeUnit import javax.inject.Inject +import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class MessageSearchActivity : BaseActivity() { @@ -51,6 +53,9 @@ class MessageSearchActivity : BaseActivity() { private lateinit var user: User + @Inject + lateinit var userManager: UserManager + private lateinit var viewModel: MessageSearchViewModel private var searchViewDisposable: Disposable? = null @@ -73,9 +78,12 @@ class MessageSearchActivity : BaseActivity() { initSystemBars() viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] - user = currentUserProviderOld.currentUser.blockingGet() + user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { + finish() + return + } val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! - viewModel.initialize(roomToken) + viewModel.initialize(user, roomToken) setupStateObserver() binding.swipeRefreshLayout.setOnRefreshListener { diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt index a91acf734fb..eea7400cd7d 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt @@ -15,7 +15,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,7 +36,6 @@ import javax.inject.Inject */ class MessageSearchViewModel @Inject constructor( private val unifiedSearchRepository: UnifiedSearchRepository, - private val currentUserProvider: CurrentUserProviderOld, private val logger: Logger ) : ViewModel() { @@ -55,13 +53,12 @@ class MessageSearchViewModel @Inject constructor( val state: LiveData get() = _state - private var currentUser: User = currentUserProvider.currentUser.blockingGet() private var searchJob: Job? = null - fun initialize(roomToken: String) { + fun initialize(user: User, roomToken: String) { messageSearchHelper = MessageSearchHelper( unifiedSearchRepository, - currentUser, + user, roomToken ) } diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt index 3307b1c52b3..ecfcacb75eb 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt @@ -15,6 +15,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager @@ -23,6 +24,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogPollCreateBinding import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.polls.adapters.PollCreateOptionItem @@ -57,7 +59,8 @@ class PollCreateDialogFragment : viewModel = ViewModelProvider(this, viewModelFactory)[PollCreateViewModel::class.java] val roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! - viewModel.setData(roomToken) + val user = BundleCompat.getParcelable(requireArguments(), KEY_USER, User::class.java)!! + viewModel.setData(user, roomToken) } @SuppressLint("InflateParams") @@ -187,10 +190,12 @@ class PollCreateDialogFragment : companion object { private val TAG = PollCreateDialogFragment::class.java.simpleName private const val KEY_ROOM_TOKEN = "keyRoomToken" + private const val KEY_USER = "keyUser" @JvmStatic - fun newInstance(roomTokenParam: String): PollCreateDialogFragment { + fun newInstance(user: User, roomTokenParam: String): PollCreateDialogFragment { val args = Bundle() + args.putParcelable(KEY_USER, user) args.putString(KEY_ROOM_TOKEN, roomTokenParam) val fragment = PollCreateDialogFragment() fragment.arguments = args diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt index 3c0a5f6154a..1c42701ca84 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt @@ -12,6 +12,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider @@ -23,7 +24,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogPollMainBinding import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -35,9 +35,6 @@ class PollMainDialogFragment : DialogFragment() { @Inject lateinit var viewThemeUtils: ViewThemeUtils - var currentUserProvider: CurrentUserProviderOld? = null - @Inject set - private lateinit var binding: DialogPollMainBinding private lateinit var viewModel: PollMainViewModel @@ -49,7 +46,7 @@ class PollMainDialogFragment : DialogFragment() { viewModel = ViewModelProvider(this, viewModelFactory)[PollMainViewModel::class.java] - user = currentUserProvider?.currentUser?.blockingGet()!! + user = BundleCompat.getParcelable(requireArguments(), KEY_USER_ENTITY, User::class.java)!! val roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! val isOwnerOrModerator = arguments?.getBoolean(KEY_OWNER_OR_MODERATOR)!! diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt index 73976fc58e9..413dee9ee02 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt @@ -99,7 +99,7 @@ class PollVoteFragment : Fragment() { } binding.pollVoteSubmitButton.setOnClickListener { - viewModel.vote(parentViewModel.roomToken, parentViewModel.pollId) + viewModel.vote(parentViewModel.user, parentViewModel.roomToken, parentViewModel.pollId) } binding.pollVoteEditDismiss.setOnClickListener { diff --git a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt index 3fda1f2b1d4..b2cb603a618 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt @@ -10,21 +10,18 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.polls.adapters.PollCreateOptionItem import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class PollCreateViewModel @Inject constructor( - private val repository: PollRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class PollCreateViewModel @Inject constructor(private val repository: PollRepository) : ViewModel() { private lateinit var roomToken: String @@ -33,7 +30,7 @@ class PollCreateViewModel @Inject constructor( object PollCreatedState : ViewState object PollCreationFailedState : ViewState - private val currentUser = currentUserProvider.currentUser.blockingGet() + private lateinit var currentUser: User private val _viewState: MutableLiveData = MutableLiveData( PollCreationState( @@ -68,7 +65,8 @@ class PollCreateViewModel @Inject constructor( addOption() } - fun setData(roomToken: String) { + fun setData(user: User, roomToken: String) { + this.currentUser = user this.roomToken = roomToken updateCreationState() } diff --git a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt index 3aa89919e5a..04dc4dace85 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt @@ -15,17 +15,13 @@ import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class PollMainViewModel @Inject constructor( - private val repository: PollRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class PollMainViewModel @Inject constructor(private val repository: PollRepository) : ViewModel() { @Inject lateinit var userManager: UserManager @@ -63,8 +59,8 @@ class PollMainViewModel @Inject constructor( private var disposable: Disposable? = null - private val currentUser = currentUserProvider.currentUser.blockingGet() - private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) + private val credentials: String? + get() = ApiUtils.getCredentials(user.username, user.token) fun setData(user: User, roomToken: String, isOwnerOrModerator: Boolean, pollId: String, pollTitle: String) { this.user = user @@ -93,7 +89,7 @@ class PollMainViewModel @Inject constructor( _viewState.value = LoadingState val url = ApiUtils.getUrlForPoll( - currentUser.baseUrl!!, + user.baseUrl!!, roomToken, pollId ) @@ -113,7 +109,7 @@ class PollMainViewModel @Inject constructor( _viewState.value = LoadingState val url = ApiUtils.getUrlForPoll( - currentUser.baseUrl!!, + user.baseUrl!!, roomToken, pollId ) @@ -187,7 +183,7 @@ class PollMainViewModel @Inject constructor( poll.resultMode == Poll.RESULT_MODE_PUBLIC && poll.votedSelf?.isNotEmpty() == true - private fun isPollCreatedByCurrentUser(poll: Poll): Boolean = currentUser.userId == poll.actorId + private fun isPollCreatedByCurrentUser(poll: Poll): Boolean = user.userId == poll.actorId fun dismissDialog() { _viewState.value = DismissDialogState diff --git a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt index c717ef2d031..7b2877e3743 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt @@ -11,20 +11,17 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class PollVoteViewModel @Inject constructor( - private val repository: PollRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class PollVoteViewModel @Inject constructor(private val repository: PollRepository) : ViewModel() { sealed interface ViewState object InitialState : ViewState @@ -50,9 +47,6 @@ class PollVoteViewModel @Inject constructor( val selectedOptions: List get() = _selectedOptions - private val currentUser = currentUserProvider.currentUser.blockingGet() - private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) - fun initVotedOptions(selectedOptions: List) { _votedOptions = selectedOptions _selectedOptions = selectedOptions @@ -70,18 +64,18 @@ class PollVoteViewModel @Inject constructor( _selectedOptions = _selectedOptions.minus(option) } - fun vote(roomToken: String, pollId: String) { + fun vote(user: User, roomToken: String, pollId: String) { if (_selectedOptions.isNotEmpty()) { _submitButtonEnabled.value = false val url = ApiUtils.getUrlForPoll( - currentUser.baseUrl!!, + user.baseUrl!!, roomToken, pollId ) repository.vote( - credentials, + ApiUtils.getCredentials(user.username, user.token), url, roomToken, pollId, From 4f34e88c15661306d40e8ea16d11007bec8fc3ca Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:14:47 +0200 Subject: [PATCH 07/76] fix(user): bind chat helper screens to their account Scheduled messages, reminders, mention autocomplete, location sharing, translation and context chat use the user of the chat they belong to instead of the active account. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 5 ++++- .../talk/chat/MessageInputFragment.kt | 1 + .../talk/chat/ScheduledMessagesActivity.kt | 2 +- .../viewmodels/ScheduledMessagesViewModel.kt | 14 ++++++------- .../talk/contextchat/ContextChatViewModel.kt | 20 ++++--------------- .../talk/dagger/modules/ViewModelModule.kt | 6 ------ .../talk/location/LocationPickerActivity.kt | 18 ++++++++++++++++- .../viewmodels/LocationPickerViewModel.kt | 12 +++++------ .../activities/MediaViewerActivity.kt | 2 +- .../messagesearch/MessageSearchActivity.kt | 2 +- .../MentionAutocompletePresenter.java | 15 ++------------ .../activities/RemoteFileBrowserActivity.kt | 2 +- .../activities/SharedItemsActivity.kt | 13 +++--------- .../adapters/SharedItemsAdapter.kt | 5 +---- .../ThreadsOverviewActivity.kt | 2 +- .../talk/translate/ui/TranslateActivity.kt | 15 ++++++++++++-- .../viewmodels/TranslateViewModel.kt | 17 ++++++++++------ .../talk/ui/dialog/DateTimeCompose.kt | 10 ++-------- .../viewmodels/LocationPickerViewModelTest.kt | 6 +++--- 19 files changed, 78 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 5b961d77851..5f85951daef 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2929,6 +2929,7 @@ class ChatActivity : if (permissionUtil.isLocationPermissionGranted() && isGpsEnabled) { val intent = Intent(this, LocationPickerActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion) startActivity(intent) @@ -3716,7 +3717,8 @@ class ChatActivity : setContent { DateTimeCompose( bundle, - chatViewModel + chatViewModel, + conversationUser ).GetDateTimeDialog(shouldDismiss, this@ChatActivity) } } @@ -3821,6 +3823,7 @@ class ChatActivity : bundle.putString(BundleKeys.KEY_TRANSLATE_MESSAGE, message?.getRichText()) val intent = Intent(this, TranslateActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.putExtras(bundle) startActivity(intent) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 6b6472b576c..8270bf5da40 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -780,6 +780,7 @@ class MessageInputFragment : Fragment() { val backgroundDrawable = it.getColor(R.color.bg_default, null).toDrawable() val presenter = MentionAutocompletePresenter( requireContext(), + chatActivity.conversationUser, chatActivity.roomToken, chatActivity.chatApiVersion ) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index aed09f6ba88..605549f6b6b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -168,7 +168,7 @@ class ScheduledMessagesActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) val currentUser by scheduledMessagesViewModel.currentUserState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - scheduledMessagesViewModel.loadCurrentUser() + scheduledMessagesViewModel.loadUser(resolveUserIdFromIntent()) } MaterialTheme(colorScheme = colorScheme) { CompositionLocalProvider( diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt index 95e5fa8b29c..144559cc508 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt @@ -15,8 +15,8 @@ import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.opengraph.ReferenceDto +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.message.SendMessageUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -30,7 +30,7 @@ import javax.inject.Inject class ScheduledMessagesViewModel @Inject constructor( private val chatRepository: ChatMessageRepository, - private val currentUserProvider: CurrentUserProvider, + private val userManager: UserManager, private val chatNetworkDataSource: ChatNetworkDataSource ) : ViewModel() { @@ -117,7 +117,7 @@ class ScheduledMessagesViewModel @Inject constructor( viewModelScope.launch { try { - val user = _currentUserState.value ?: currentUserProvider.getCurrentUser().getOrNull() ?: return@launch + val user = _currentUserState.value ?: return@launch if (user.baseUrl.isNullOrBlank()) { return@launch } @@ -143,10 +143,9 @@ class ScheduledMessagesViewModel @Inject constructor( } } - fun loadCurrentUser() { + fun loadUser(userId: Long) { viewModelScope.launch { - val user = currentUserProvider.getCurrentUser().getOrNull() - _currentUserState.value = user + _currentUserState.value = userManager.getUserWithId(userId) } } @@ -219,8 +218,7 @@ class ScheduledMessagesViewModel @Inject constructor( private suspend fun getParentMessageById(token: String, parentMessageId: Long, threadId: Long?): ChatMessage? = withContext(Dispatchers.IO) { - val userResult = currentUserProvider.getCurrentUser() - val user = userResult.getOrElse { return@withContext null } + val user = _currentUserState.value ?: return@withContext null val credentials = user.getCredentials() diff --git a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt index e859da295c7..89d965aed47 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt @@ -13,8 +13,8 @@ import androidx.lifecycle.viewModelScope import autodagger.AutoInjector import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chat.ChatMessageDto -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -24,9 +24,6 @@ import javax.inject.Inject class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource: ChatNetworkDataSource) : ViewModel() { - @Inject - lateinit var currentUserProvider: CurrentUserProvider - var threadId: String? = null private val _getContextChatMessagesState = @@ -34,17 +31,8 @@ class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource val getContextChatMessagesState: StateFlow = _getContextChatMessagesState @Suppress("LongParameterList") - fun getContextForChatMessages( - credentials: String, - baseUrl: String, - token: String, - threadId: String?, - messageId: String, - title: String - ) { + fun getContextForChatMessages(user: User, token: String, threadId: String?, messageId: String, title: String) { viewModelScope.launch { - val user = currentUserProvider.getCurrentUser().getOrThrow() - if (!user.hasSpreedFeatureCapability("chat-get-context") || !user.hasSpreedFeatureCapability("federation-v1") ) { @@ -53,8 +41,8 @@ class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource try { var messages = chatNetworkDataSource.getContextForChatMessage( - credentials = credentials, - baseUrl = baseUrl, + credentials = user.getCredentials(), + baseUrl = user.baseUrl!!, token = token, messageId = messageId, limit = LIMIT, diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 79d7989d1e2..1809a44e532 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -33,7 +33,6 @@ import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel -import com.nextcloud.talk.translate.viewmodels.TranslateViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel import dagger.Binds @@ -120,11 +119,6 @@ abstract class ViewModelModule { @ViewModelKey(RaiseHandViewModel::class) abstract fun raiseHandViewModel(viewModel: RaiseHandViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(TranslateViewModel::class) - abstract fun translateViewModel(viewModel: TranslateViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(OpenConversationsViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index 80b3aaa0b1f..ba13a44011e 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.location import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -20,10 +21,12 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.extensions.getParcelableExtraProvider import com.nextcloud.talk.location.components.LocationPickerScreen +import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_GEOCODING_RESULT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel +import kotlinx.coroutines.runBlocking import javax.inject.Inject private const val MAP_CENTER_LAT_KEY = "mapCenterLat" @@ -34,12 +37,17 @@ private const val MOVE_TO_CURRENT_LOCATION_KEY = "moveToCurrentLocation" private const val GEOCODING_RESULT_KEY = "geocodingResult" +private const val TAG = "LocationPickerActivity" + @AutoInjector(NextcloudTalkApplication::class) class LocationPickerActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var userManager: UserManager + private lateinit var viewModel: LocationPickerViewModel private lateinit var roomToken: String private var chatApiVersion: Int = 1 @@ -70,6 +78,13 @@ class LocationPickerActivity : BaseActivity() { roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + Log.e(TAG, "No user found") + finish() + return + } + viewModel = ViewModelProvider(this, viewModelFactory)[LocationPickerViewModel::class.java] val geocodingResult: GeocodingResult? = if (savedInstanceState != null) { @@ -84,6 +99,7 @@ class LocationPickerActivity : BaseActivity() { viewModel.initialize( LocationPickerViewModel.LocationPickerInitParams( + user = user, roomToken = roomToken, chatApiVersion = chatApiVersion, geocodingResult = geocodingResult, diff --git a/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt b/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt index 31d5a8b8962..745dc9a4344 100644 --- a/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt @@ -11,11 +11,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.location.GeocodingResult import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import fr.dudie.nominatim.client.TalkJsonNominatimClient import fr.dudie.nominatim.model.Address import io.reactivex.Observer @@ -35,7 +35,6 @@ import javax.inject.Inject @Suppress("TooManyFunctions") class LocationPickerViewModel @Inject constructor( private val ncApi: NcApi, - private val currentUserProviderOld: CurrentUserProviderOld, private val okHttpClient: OkHttpClient, private val logger: Logger ) : ViewModel() { @@ -61,6 +60,7 @@ class LocationPickerViewModel @Inject constructor( ) data class LocationPickerInitParams( + val user: User, val roomToken: String, val chatApiVersion: Int, val geocodingResult: GeocodingResult?, @@ -78,6 +78,7 @@ class LocationPickerViewModel @Inject constructor( private var isStateInitialized = false + private lateinit var user: User private var roomToken: String = "" private var chatApiVersion: Int = 1 @@ -88,6 +89,7 @@ class LocationPickerViewModel @Inject constructor( if (isStateInitialized) return isStateInitialized = true + user = params.user roomToken = params.roomToken chatApiVersion = params.chatApiVersion nominatimClient = TalkJsonNominatimClient(params.geocoderBaseUrl, okHttpClient, params.geocoderEmail) @@ -247,11 +249,9 @@ class LocationPickerViewModel @Inject constructor( "\"latitude\":\"$selectedLat\"," + "\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}" - val currentUser = currentUserProviderOld.currentUser.blockingGet() - ncApi.sendLocation( - ApiUtils.getCredentials(currentUser.username, currentUser.token), - ApiUtils.getUrlToSendLocation(chatApiVersion, currentUser.baseUrl!!, roomToken), + ApiUtils.getCredentials(user.username, user.token), + ApiUtils.getUrlToSendLocation(chatApiVersion, user.baseUrl!!, roomToken), "geo-location", objectId, metaData diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index a7db1d5e483..add4abc8908 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -37,9 +37,9 @@ import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import kotlinx.coroutines.runBlocking import java.io.File import javax.inject.Inject -import kotlinx.coroutines.runBlocking /** * Swipeable, group-aware media viewer - the entry point for every image/video tap in chat. See diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 2c1922c8fd7..4bde9404960 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -38,9 +38,9 @@ import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking import java.util.concurrent.TimeUnit import javax.inject.Inject -import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class MessageSearchActivity : BaseActivity() { diff --git a/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java b/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java index b98cad526d8..8d6d286857e 100644 --- a/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java +++ b/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java @@ -23,7 +23,6 @@ import com.nextcloud.talk.ui.theme.ViewThemeUtils; import com.nextcloud.talk.users.UserManager; import com.nextcloud.talk.utils.ApiUtils; -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld; import com.otaliastudios.autocomplete.RecyclerViewPresenter; import java.util.ArrayList; @@ -52,9 +51,6 @@ public class MentionAutocompletePresenter extends RecyclerViewPresenter when (state) { diff --git a/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt b/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt index 6e561fc7030..c19476f7baa 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt @@ -16,16 +16,17 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.translate.repositories.TranslateRepository import com.nextcloud.talk.translate.repositories.model.LanguageDto import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import javax.inject.Inject -class TranslateViewModel @Inject constructor( +class TranslateViewModel @AssistedInject constructor( private val repository: TranslateRepository, - private val currentUserProvider: CurrentUserProviderOld + @Assisted private val currentUser: User ) : ViewModel() { sealed interface ViewState @@ -44,7 +45,6 @@ class TranslateViewModel @Inject constructor( get() = _viewState fun translateMessage(toLanguage: String, fromLanguage: String?, text: String) { - val currentUser: User = currentUserProvider.currentUser.blockingGet() val authorization: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! val url: String = ApiUtils.getUrlForTranslation(currentUser.baseUrl!!) val calculatedFromLanguage = @@ -67,7 +67,6 @@ class TranslateViewModel @Inject constructor( } fun getLanguages() { - val currentUser: User = currentUserProvider.currentUser.blockingGet() val authorization: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! val url: String = ApiUtils.getUrlForLanguages(currentUser.baseUrl!!) Log.d(TAG, "URL is: $url") @@ -113,6 +112,12 @@ class TranslateViewModel @Inject constructor( // nothing? } } + + @AssistedFactory + interface Factory { + fun build(user: User): TranslateViewModel + } + companion object { private val TAG = TranslateViewModel::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt index 3842ff06d61..1a27432c5b3 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt @@ -63,10 +63,10 @@ import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.dialog.DateTimeCompose.Companion.HALF_WEIGHT import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preview.ComposePreviewUtils import java.time.DayOfWeek import java.time.Instant @@ -79,21 +79,17 @@ import java.time.temporal.TemporalAdjusters.nextOrSame import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { +class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel, val user: User) { private var timeState = mutableStateOf(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.MIN)) init { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = currentUserProvider.currentUser.blockingGet() val roomToken = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!! val messageId = bundle.getInt(BundleKeys.KEY_MESSAGE_ID) val apiVersion = bundle.getInt(BundleKeys.KEY_CHAT_API_VERSION) chatViewModel.getReminder(user, roomToken, messageId.toString(), apiVersion) } - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -141,7 +137,6 @@ class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { ) { TextButton( onClick = { - val user = currentUserProvider.currentUser.blockingGet() val roomToken = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!! val messageId = bundle.getInt(BundleKeys.KEY_MESSAGE_ID) val apiVersion = bundle.getInt(BundleKeys.KEY_CHAT_API_VERSION) @@ -169,7 +164,6 @@ class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { TextButton( onClick = { - val user = currentUserProvider.currentUser.blockingGet() val roomToken = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!! val messageId = bundle.getInt(BundleKeys.KEY_MESSAGE_ID) val apiVersion = bundle.getInt(BundleKeys.KEY_CHAT_API_VERSION) diff --git a/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt index 571d08110c1..7bb71d483d8 100644 --- a/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt @@ -7,9 +7,9 @@ package com.nextcloud.talk.location.viewmodels import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.location.GeocodingResult import com.nextcloud.talk.logger.Logger -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -30,11 +30,11 @@ class LocationPickerViewModelTest { private lateinit var viewModel: LocationPickerViewModel private val ncApi: NcApi = mock() - private val userProvider: CurrentUserProviderOld = mock() private val okHttpClient: OkHttpClient = mock() private val logger: Logger = mock() private val initParams = LocationPickerViewModel.LocationPickerInitParams( + user = User(id = 1L, username = "user1", baseUrl = "https://server1", token = "token"), roomToken = "testRoom", chatApiVersion = 1, geocodingResult = null, @@ -48,7 +48,7 @@ class LocationPickerViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - viewModel = LocationPickerViewModel(ncApi, userProvider, okHttpClient, logger) + viewModel = LocationPickerViewModel(ncApi, okHttpClient, logger) viewModel.initialize(initParams) } From 8314a2f31b9e1883714aaa90b0ed56ce9659026f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:20:24 +0200 Subject: [PATCH 08/76] fix(user): bind contacts, conversation creation and invitations to their account ContactsViewModel, ConversationCreationViewModel and OpenConversationsViewModel get the user of their screen via assisted injection. Contacts, conversation creation, open conversations and invitations use the user id they were started with; all launchers pass it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/MainActivity.kt | 1 + .../talk/contacts/ContactsActivity.kt | 19 +++++- .../nextcloud/talk/contacts/ContactsScreen.kt | 2 +- .../talk/contacts/ContactsViewModel.kt | 49 +++++--------- .../components/ConversationCreationOptions.kt | 7 +- .../ConversationCreationActivity.kt | 20 +++++- .../ConversationCreationViewModel.kt | 17 +++-- .../ConversationsListActivity.kt | 13 +++- .../talk/dagger/modules/ViewModelModule.kt | 18 ----- .../talk/invitation/InvitationsActivity.kt | 12 +++- .../ListOpenConversationsActivity.kt | 25 +++++-- .../viewmodels/OpenConversationsViewModel.kt | 66 ++++++++++--------- .../talk/utils/preview/ComposePreviewUtils.kt | 14 ++-- .../talk/contacts/ContactsViewModelTest.kt | 14 +--- .../ConversationCreationViewModelTest.kt | 10 +-- 15 files changed, 158 insertions(+), 129 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index b481a6e62b3..f8192aa4237 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -250,6 +250,7 @@ class MainActivity : if (intent.hasExtra(BundleKeys.KEY_REMOTE_TALK_SHARE)) { if (intent.getBooleanExtra(BundleKeys.KEY_REMOTE_TALK_SHARE, false)) { val invitationsIntent = Intent(this@MainActivity, InvitationsActivity::class.java) + invitationsIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) startActivity(invitationsIntent) } } else { diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index eacfc72f4e5..98ac2b9af4c 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -20,23 +20,38 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class ContactsActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ContactsViewModel.Factory + + @Inject + lateinit var userManager: UserManager + private lateinit var contactsViewModel: ContactsViewModel @SuppressLint("UnrememberedMutableState") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - contactsViewModel = ViewModelProvider(this, viewModelFactory)[ContactsViewModel::class.java] + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + finish() + return + } + contactsViewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(ContactsViewModel::class.java) { viewModelFactory.build(user) } + )[ContactsViewModel::class.java] setContent { val isAddParticipants = intent.getBooleanExtra(BundleKeys.KEY_ADD_PARTICIPANTS, false) val hideAlreadyAddedParticipants = intent.getBooleanExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, false) diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt index e77ec253d4f..6d403ea441a 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt @@ -70,7 +70,7 @@ fun ContactsScreen(contactsViewModel: ContactsViewModel, uiState: ContactsViewMo .padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, paddingValues.calculateBottomPadding()) ) { if (!isAddParticipants) { - ConversationCreationOptions() + ConversationCreationOptions(contactsViewModel.currentUser.id!!) } ContactsList( diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt index d4ed662381a..fef331fcec9 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt @@ -13,22 +13,21 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.models.json.conversations.ConversationDto -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -class ContactsViewModel @Inject constructor( +class ContactsViewModel @AssistedInject constructor( private val repository: ContactsRepository, - private val currentUserProvider: CurrentUserProvider, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { private val _contactsViewState = MutableStateFlow(ContactsUiState.None) @@ -57,12 +56,10 @@ class ContactsViewModel @Inject constructor( private var hideAlreadyAddedParticipants: Boolean = false - private val currentUserFlow: StateFlow = - currentUserProvider.currentUserFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - - private val currentUser: User? - get() = currentUserFlow.value + @AssistedFactory + interface Factory { + fun build(user: User): ContactsViewModel + } companion object { private val TAG = ContactsViewModel::class.java.simpleName @@ -114,10 +111,6 @@ class ContactsViewModel @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun getContactsFromSearchParams(query: String = "") { val user = currentUser - if (user == null) { - _contactsViewState.value = ContactsUiState.Error("No current user") - return - } _contactsViewState.value = ContactsUiState.Loading viewModelScope.launch { try { @@ -150,10 +143,6 @@ class ContactsViewModel @Inject constructor( suspend fun getBlockingContactsFromSearchParams(query: String = "") = withContext(Dispatchers.IO) { val user = currentUser - if (user == null) { - _contactsViewState.value = ContactsUiState.Error("No current user") - return@withContext - } _contactsViewState.value = ContactsUiState.Loading try { val contacts = repository.getContacts( @@ -183,10 +172,6 @@ class ContactsViewModel @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun createRoom(roomType: String, sourceType: String?, userId: String, conversationName: String?) { val user = currentUser - if (user == null) { - _roomViewState.value = RoomUiState.Error("No current user") - return - } viewModelScope.launch { try { val room = repository.createRoom( @@ -207,14 +192,12 @@ class ContactsViewModel @Inject constructor( } fun getImageUri(avatarId: String, requestBigSize: Boolean, isDarkMode: Boolean): String = - currentUser?.let { - repository.getImageUri( - it, - avatarId, - requestBigSize, - isDarkMode - ) - } ?: "" + repository.getImageUri( + currentUser, + avatarId, + requestBigSize, + isDarkMode + ) sealed class ContactsUiState { data object None : ContactsUiState() diff --git a/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt b/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt index a5527eb5f3b..f035cf41048 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt @@ -34,9 +34,10 @@ import androidx.compose.ui.unit.sp import com.nextcloud.talk.R import com.nextcloud.talk.conversationcreation.ConversationCreationActivity import com.nextcloud.talk.openconversations.ListOpenConversationsActivity +import com.nextcloud.talk.utils.bundle.BundleKeys @Composable -fun ConversationCreationOptions() { +fun ConversationCreationOptions(userId: Long) { val context = LocalContext.current Column { Row( @@ -44,6 +45,7 @@ fun ConversationCreationOptions() { .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) .clickable { val intent = Intent(context, ConversationCreationActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) context.startActivity(intent) }, verticalAlignment = Alignment.CenterVertically @@ -70,6 +72,7 @@ fun ConversationCreationOptions() { .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .clickable { val intent = Intent(context, ListOpenConversationsActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) context.startActivity(intent) }, verticalAlignment = Alignment.CenterVertically @@ -98,7 +101,7 @@ fun ConversationCreationOptions() { fun ConversationCreationOptionsPreview() { MaterialTheme { Surface { - ConversationCreationOptions() + ConversationCreationOptions(userId = 1L) } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index c74527c0dc7..0edc807dd8a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -101,11 +101,13 @@ import com.nextcloud.talk.conversationcreation.ui.CreationResultEffect import com.nextcloud.talk.conversationcreation.ui.ShareCreatedConversation import com.nextcloud.talk.conversationcreation.ui.openConversation import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.passwordpolicy.PasswordPolicyField import com.nextcloud.talk.passwordpolicy.isPasswordAccepted +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.copyPasswordToClipboard import com.nextcloud.talk.utils.DisplayUtils @@ -117,15 +119,21 @@ import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class ConversationCreationActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ConversationCreationViewModel.Factory + + @Inject + lateinit var userManager: UserManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val userId = resolveUserIdFromIntent() val conversationCreationViewModel = ViewModelProvider( this, - viewModelFactory + ViewModelFactoryWithParams(ConversationCreationViewModel::class.java) { + viewModelFactory.build(userManager.userFlow(userId)) + } )[ConversationCreationViewModel::class.java] setContent { val colorScheme = viewThemeUtils.getColorScheme(this) @@ -414,6 +422,10 @@ fun AddParticipants( .padding(start = 16.dp, bottom = 16.dp) .clickable { val intent = Intent(context, ContactsActivity::class.java) + intent.putExtra( + BundleKeys.KEY_INTERNAL_USER_ID, + conversationCreationViewModel.currentUser.value?.id + ) intent.putParcelableArrayListExtra( "selectedParticipants", participants as ArrayList @@ -461,6 +473,10 @@ fun AddParticipants( .fillMaxWidth() .clickable { val intent = Intent(context, ContactsActivity::class.java) + intent.putExtra( + BundleKeys.KEY_INTERNAL_USER_ID, + conversationCreationViewModel.currentUser.value?.id + ) intent.putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) intent.putExtra( BundleKeys.KEY_ONLY_LOCAL_PARTICIPANTS, diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index 8753b69f431..60c2c4c201e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -33,9 +33,12 @@ import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -43,13 +46,12 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -class ConversationCreationViewModel @Inject constructor( +class ConversationCreationViewModel @AssistedInject constructor( private val repository: ConversationCreationRepository, private val conversationCreator: ConversationCreator, private val passwordPolicyRepository: PasswordPolicyRepository, - currentUserProvider: CurrentUserProvider + @Assisted userFlow: Flow ) : ViewModel() { private val _selectedParticipants = MutableStateFlow>(emptyList()) val selectedParticipants: StateFlow> = _selectedParticipants @@ -72,7 +74,7 @@ class ConversationCreationViewModel @Inject constructor( private val _isCreatingRoom = MutableStateFlow(false) val isCreatingRoom: StateFlow = _isCreatingRoom - val currentUser: StateFlow = currentUserProvider.currentUserFlow + val currentUser: StateFlow = userFlow .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val spreedCapabilities @@ -298,6 +300,11 @@ class ConversationCreationViewModel @Inject constructor( roomViewState.value = RoomUIState.None } + @AssistedFactory + interface Factory { + fun build(userFlow: Flow): ConversationCreationViewModel + } + companion object { private val TAG = ConversationCreationViewModel::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 49e77a5e199..f6aa852145d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -151,6 +151,8 @@ class ConversationsListActivity : BaseActivity() { lateinit var networkMonitor: NetworkMonitor @Inject + lateinit var contactsViewModelFactory: ContactsViewModel.Factory + lateinit var contactsViewModel: ContactsViewModel lateinit var conversationsListViewModel: ConversationsListViewModel @@ -223,6 +225,10 @@ class ConversationsListActivity : BaseActivity() { conversationTagsViewModelFactory.build(user) } )[ConversationTagsViewModel::class.java] + contactsViewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(ContactsViewModel::class.java) { contactsViewModelFactory.build(user) } + )[ContactsViewModel::class.java] setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) @@ -337,7 +343,11 @@ class ConversationsListActivity : BaseActivity() { settingsIntent.putExtras(bundle) startActivity(settingsIntent) }, - onFederationHintClick = { startActivity(Intent(context, InvitationsActivity::class.java)) }, + onFederationHintClick = { + val intent = Intent(context, InvitationsActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + startActivity(intent) + }, onFilterClick = { FilterConversationFragment .newInstance( @@ -800,6 +810,7 @@ class ConversationsListActivity : BaseActivity() { private fun showNewConversationsScreen() { val intent = Intent(context, ContactsActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(intent) } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 1809a44e532..9823b71d85d 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -15,9 +15,7 @@ import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewViewModel import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel -import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel -import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel import com.nextcloud.talk.diagnosis.DiagnosisViewModel @@ -25,7 +23,6 @@ import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.messagesearch.MessageSearchViewModel -import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToViewModel import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.polls.viewmodels.PollMainViewModel @@ -119,11 +116,6 @@ abstract class ViewModelModule { @ViewModelKey(RaiseHandViewModel::class) abstract fun raiseHandViewModel(viewModel: RaiseHandViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(OpenConversationsViewModel::class) - abstract fun openConversationsViewModel(viewModel: OpenConversationsViewModel): ViewModel - // @Binds // @IntoMap // @ViewModelKey(ChatViewModel::class) @@ -144,16 +136,6 @@ abstract class ViewModelModule { @ViewModelKey(InvitationsViewModel::class) abstract fun invitationsViewModel(viewModel: InvitationsViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ContactsViewModel::class) - abstract fun contactsViewModel(viewModel: ContactsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ConversationCreationViewModel::class) - abstract fun conversationCreationViewModel(viewModel: ConversationCreationViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(DiagnosisViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index 9bf6e154e98..1c345affa1e 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -26,6 +26,9 @@ import com.nextcloud.talk.invitation.adapters.InvitationsAdapter import com.nextcloud.talk.invitation.data.ActionEnum import com.nextcloud.talk.invitation.data.Invitation import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -39,6 +42,9 @@ class InvitationsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var userManager: UserManager + lateinit var invitationsViewModel: InvitationsViewModel lateinit var adapter: InvitationsAdapter @@ -48,6 +54,7 @@ class InvitationsActivity : BaseActivity() { private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val intent = Intent(this@InvitationsActivity, ConversationsListActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser.id) startActivity(intent) } } @@ -58,7 +65,10 @@ class InvitationsActivity : BaseActivity() { invitationsViewModel = ViewModelProvider(this, viewModelFactory)[InvitationsViewModel::class.java] - currentUser = currentUserProviderOld.currentUser.blockingGet() + currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { + finish() + return + } invitationsViewModel.fetchInvitations(currentUser) binding = ActivityInvitationsBinding.inflate(layoutInflater) diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index 2db5f0cb649..3781d8217a6 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -18,16 +18,22 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.adjustUIForAPILevel35 +import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class ListOpenConversationsActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: OpenConversationsViewModel.Factory + + @Inject + lateinit var userManager: UserManager private lateinit var openConversationsViewModel: OpenConversationsViewModel @@ -36,10 +42,17 @@ class ListOpenConversationsActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - openConversationsViewModel = ViewModelProvider(this, viewModelFactory)[OpenConversationsViewModel::class.java] - openConversationsViewModel.fetchConversations() + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + finish() + return + } - val user = currentUserProviderOld.currentUser.blockingGet() + openConversationsViewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(OpenConversationsViewModel::class.java) { viewModelFactory.build(user) } + )[OpenConversationsViewModel::class.java] + openConversationsViewModel.fetchConversations() setContent { val colorScheme = viewThemeUtils.getColorScheme(this) @@ -51,13 +64,13 @@ class ListOpenConversationsActivity : BaseActivity() { OpenConversationsScreen( viewState = viewState, searchTerm = searchTerm, - userBaseUrl = user?.baseUrl, + userBaseUrl = user.baseUrl, listenerInput = OpenConversationsScreenListenerInput( onSearchTermChange = { term -> openConversationsViewModel.updateSearchTerm(term) openConversationsViewModel.fetchConversations() }, - onConversationClick = { conversation -> user?.id?.let { navigateToChat(it, conversation) } }, + onConversationClick = { conversation -> navigateToChat(user.id!!, conversation) }, onBackClick = { onBackPressedDispatcher.onBackPressed() } ) ) diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt b/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt index a9cb741444b..33ea9fe78be 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt @@ -9,18 +9,20 @@ package com.nextcloud.talk.openconversations.viewmodels import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.data.OpenConversationsRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -class OpenConversationsViewModel @Inject constructor( +class OpenConversationsViewModel @AssistedInject constructor( private val repository: OpenConversationsRepository, - private val currentUserProvider: CurrentUserProvider + @Assisted private val user: User ) : ViewModel() { sealed interface ViewState @@ -42,34 +44,31 @@ class OpenConversationsViewModel @Inject constructor( _viewState.value = FetchConversationsStartState viewModelScope.launch { - currentUserProvider.getCurrentUser() - .onSuccess { - val apiVersion = ApiUtils.getConversationApiVersion( - it, - intArrayOf( - ApiUtils.API_V4, - ApiUtils.API_V3, - 1 - ) - ) - val url = ApiUtils.getUrlForOpenConversations(apiVersion, it.baseUrl!!) + val apiVersion = ApiUtils.getConversationApiVersion( + user, + intArrayOf( + ApiUtils.API_V4, + ApiUtils.API_V3, + 1 + ) + ) + val url = ApiUtils.getUrlForOpenConversations(apiVersion, user.baseUrl!!) - repository.fetchConversations( - it, - url, - _searchTerm.value - ) - .onSuccess { conversations -> - if (conversations.isEmpty()) { - _viewState.value = FetchConversationsEmptyState - } else { - _viewState.value = FetchConversationsSuccessState(conversations) - } - } - .onFailure { exception -> - Log.e(TAG, "Failed to fetch conversations", exception) - _viewState.value = FetchConversationsErrorState - } + repository.fetchConversations( + user, + url, + _searchTerm.value + ) + .onSuccess { conversations -> + if (conversations.isEmpty()) { + _viewState.value = FetchConversationsEmptyState + } else { + _viewState.value = FetchConversationsSuccessState(conversations) + } + } + .onFailure { exception -> + Log.e(TAG, "Failed to fetch conversations", exception) + _viewState.value = FetchConversationsErrorState } } } @@ -78,6 +77,11 @@ class OpenConversationsViewModel @Inject constructor( _searchTerm.value = newTerm } + @AssistedFactory + interface Factory { + fun build(user: User): OpenConversationsViewModel + } + companion object { private val TAG = OpenConversationsViewModel::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index a37d5903044..2adf22f7064 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -44,6 +44,7 @@ import com.nextcloud.talk.data.network.NetworkMonitorImpl import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepositoryImpl import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.reactions.ReactionsRepositoryImpl @@ -55,7 +56,6 @@ import com.nextcloud.talk.ui.theme.MaterialSchemesProviderImpl import com.nextcloud.talk.ui.theme.TalkSpecificViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.logger.Logger @@ -66,6 +66,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory @@ -98,6 +100,9 @@ class ComposePreviewUtils private constructor(context: Context) { val userProvider: CurrentUserProviderOld get() = CurrentUserProviderOldImpl(userManager) + val currentUser: User + get() = runBlocking { userManager.getCurrentUser() }!! + val colorUtil: ColorUtil get() = ColorUtil(mContext) @@ -209,9 +214,6 @@ class ComposePreviewUtils private constructor(context: Context) { val audioFocusRequestManager: AudioFocusRequestManager get() = AudioFocusRequestManager(mContext) - val currentUserProvider: CurrentUserProviderImpl - get() = CurrentUserProviderImpl(userManager) - object TestLogger : Logger { override fun d(tag: String, message: String) = Unit override fun d(tag: String, message: String, t: Throwable) = Unit @@ -245,7 +247,7 @@ class ComposePreviewUtils private constructor(context: Context) { get() = ContactsRepositoryImpl(ncApiCoroutines) val contactsViewModel: ContactsViewModel - get() = ContactsViewModel(contactsRepository, currentUserProvider, TestLogger) + get() = ContactsViewModel(contactsRepository, TestLogger, currentUser) val conversationCreationViewModel: ConversationCreationViewModel get() = ConversationCreationRepositoryImpl(ncApiCoroutines).let { repository -> @@ -253,7 +255,7 @@ class ComposePreviewUtils private constructor(context: Context) { repository, ConversationCreator(repository, TestLogger), PasswordPolicyRepositoryImpl(ncApiCoroutines), - currentUserProvider + flowOf(currentUser) ) } } diff --git a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt index 55c3b2f1bdf..57362f88165 100644 --- a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt @@ -16,8 +16,6 @@ import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProvider -import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl import com.nextcloud.talk.utils.preview.DummyUserDaoImpl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -49,19 +47,11 @@ class ContactsViewModelTest { val userManager: UserManager get() = UserManager(userRepository) - val userProvider: CurrentUserProvider - get() = CurrentUserProviderImpl(userManager) - private var viewModelCount = 0 private fun createViewModel(repo: ContactsRepository): ContactsViewModel { - val provider = userProvider - // CurrentUserProviderImpl feeds its currentUserFlow from a real Dispatchers.IO - // scope, independent of the test dispatcher's virtual time. Waiting for it here - // (real blocking wait, since this isn't inside runTest) ensures the value is - // already cached by the time the ViewModel's viewModelScope collects it. - runBlocking { provider.getCurrentUser() } - return ContactsViewModel(repo, provider, mock()).also { + val user = runBlocking { userManager.getCurrentUser() }!! + return ContactsViewModel(repo, mock(), user).also { viewModelStore.put("contactsViewModel${viewModelCount++}", it) } } diff --git a/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt index 31d7439ba58..2a7f5d2e669 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt @@ -16,10 +16,8 @@ import com.nextcloud.talk.models.json.capabilities.PasswordApiDto import com.nextcloud.talk.models.json.capabilities.PasswordPolicyDto import com.nextcloud.talk.passwordpolicy.FakePasswordPolicyRepository import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -43,12 +41,6 @@ class ConversationCreationViewModelTest { private val users = MutableSharedFlow(replay = 1) private val repository = FakeConversationCreationRepository() - private val userProvider = object : CurrentUserProvider { - override val currentUserFlow: Flow = users - override suspend fun getCurrentUser(timeout: Long): Result = - Result.failure(UnsupportedOperationException()) - } - private val passwordPolicyRepository = FakePasswordPolicyRepository() private fun viewModel() = @@ -56,7 +48,7 @@ class ConversationCreationViewModelTest { repository, ConversationCreator(repository, mock()), passwordPolicyRepository, - userProvider + users ) private fun user(vararg spreedFeatures: SpreedFeatures, passwordEnforced: Boolean = false) = From 5ca219643599f3783733784b070d91021cfdb5f8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:23:31 +0200 Subject: [PATCH 09/76] fix(user): bind settings, profile and diagnosis to their account SettingsActivity, ProfileActivity and DiagnosisActivity use the user id they were started with; the conversation list and settings pass it. DiagnosisViewModel gets the user via assisted injection and the diagnosis account section shows that user. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 5 ++++- .../talk/dagger/modules/ViewModelModule.kt | 6 ----- .../talk/diagnosis/DiagnosisActivity.kt | 17 ++++++++++---- .../talk/diagnosis/DiagnosisElement.kt | 8 ++++--- .../talk/diagnosis/DiagnosisViewModel.kt | 22 +++++++++++-------- .../nextcloud/talk/profile/ProfileActivity.kt | 3 ++- .../talk/settings/SettingsActivity.kt | 13 +++++++---- 7 files changed, 46 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index f6aa852145d..32faa93a438 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -341,6 +341,7 @@ class ConversationsListActivity : BaseActivity() { bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) val settingsIntent = Intent(context, SettingsActivity::class.java) settingsIntent.putExtras(bundle) + settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(settingsIntent) }, onFederationHintClick = { @@ -361,7 +362,9 @@ class ConversationsListActivity : BaseActivity() { if (resources.getBoolean(R.bool.multiaccount_support)) { showChooseAccountDialog() } else { - startActivity(Intent(context, SettingsActivity::class.java)) + val settingsIntent = Intent(context, SettingsActivity::class.java) + settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + startActivity(settingsIntent) } }, onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 9823b71d85d..111bf0f6402 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -18,7 +18,6 @@ import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel -import com.nextcloud.talk.diagnosis.DiagnosisViewModel import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel @@ -136,11 +135,6 @@ abstract class ViewModelModule { @ViewModelKey(InvitationsViewModel::class) abstract fun invitationsViewModel(viewModel: InvitationsViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(DiagnosisViewModel::class) - abstract fun diagnosisViewModel(viewModel: DiagnosisViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(LogsViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index ebd4d0c0ed2..d630be2a537 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -39,6 +39,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.errorhandling.saveLogsAsZip import com.nextcloud.talk.logger.LogsRepository @@ -48,6 +49,7 @@ import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -57,7 +59,7 @@ class DiagnosisActivity : BaseActivity() { lateinit var arbitraryStorageManager: ArbitraryStorageManager @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: DiagnosisViewModel.Factory @Inject lateinit var ncApi: NcApi @@ -74,6 +76,7 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var logsRepository: LogsRepository + private lateinit var diagnosisViewModel: DiagnosisViewModel private val diagnosisData = mutableListOf() private val diagnosisDataState = mutableStateOf(emptyList()) @@ -92,9 +95,14 @@ class DiagnosisActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val diagnosisViewModel = ViewModelProvider( + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + if (user == null) { + finish() + return + } + diagnosisViewModel = ViewModelProvider( this, - viewModelFactory + ViewModelFactoryWithParams(DiagnosisViewModel::class.java) { viewModelFactory.build(user) } )[DiagnosisViewModel::class.java] val colorScheme = viewThemeUtils.getColorScheme(this) @@ -128,7 +136,8 @@ class DiagnosisActivity : BaseActivity() { userManager, appPreferences, arbitraryStorageManager, - logsRepository + logsRepository, + diagnosisViewModel.currentUser ) ) diagnosisDataState.value = diagnosisData.toList() diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt index 081ea411f8e..16ee9f606c4 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt @@ -15,6 +15,7 @@ import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -56,13 +57,14 @@ private const val PUSH_TOKEN_PREFIX_END: Int = 5 private val TAG = DiagnosisActivity::class.java.simpleName -@Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod") +@Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod", "LongParameterList") fun buildDiagnosisElements( context: Context, userManager: UserManager, appPreferences: AppPreferences, arbitraryStorageManager: ArbitraryStorageManager? = null, - logsRepository: LogsRepository + logsRepository: LogsRepository, + accountUser: User? = null ): List { val data = mutableListOf() @@ -201,7 +203,7 @@ fun buildDiagnosisElements( // Account try { - val user = runBlocking { userManager.getCurrentUser() } ?: return data + val user = accountUser ?: runBlocking { userManager.getCurrentUser() } ?: return data addHeadline(context.getString(R.string.nc_diagnosis_account_category_title)) addEntry(context.getString(R.string.nc_diagnosis_account_server), user.baseUrl ?: "") addEntry(context.getString(R.string.nc_diagnosis_account_user_name), user.displayName ?: "") diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt index cb5d7564cfb..1bebc8cca9a 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt @@ -14,21 +14,20 @@ import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("TooGenericExceptionCaught") -class DiagnosisViewModel @Inject constructor( +class DiagnosisViewModel @AssistedInject constructor( private val ncApiCoroutines: NcApiCoroutines, - private val currentUserProvider: CurrentUserProviderOld, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { - private val _currentUser = currentUserProvider.currentUser.blockingGet() - val currentUser: User = _currentUser - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val _notificationViewState = MutableStateFlow(NotificationUiState.None) val notificationViewState: StateFlow = _notificationViewState @@ -46,7 +45,7 @@ class DiagnosisViewModel @Inject constructor( val response = ncApiCoroutines.testPushNotifications( credentials, ApiUtils - .getUrlForTestPushNotifications(_currentUser.baseUrl ?: "") + .getUrlForTestPushNotifications(currentUser.baseUrl ?: "") ) val notificationMessage = response.ocs?.data?.message _notificationViewState.value = NotificationUiState.Success(notificationMessage) @@ -64,6 +63,11 @@ class DiagnosisViewModel @Inject constructor( _showDialog.value = false } + @AssistedFactory + interface Factory { + fun build(user: User): DiagnosisViewModel + } + companion object { private val TAG = DiagnosisViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index d843d7d01c7..2cd07031134 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -47,6 +47,7 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody @@ -172,7 +173,7 @@ class ProfileActivity : BaseActivity() { override fun onResume() { super.onResume() - currentUser = currentUserProviderOld.currentUser.blockingGet() + currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) pickImage = PickImage(this, currentUser) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 7bf5bda7f8e..9523fa8bde6 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -43,6 +43,7 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -277,6 +278,7 @@ class SettingsActivity : binding.settingsName.visibility = View.VISIBLE binding.settingsName.setOnClickListener { val intent = Intent(this, ProfileActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(intent) } @@ -336,7 +338,7 @@ class SettingsActivity : } private fun getCurrentUser() { - currentUser = currentUserProviderOld.currentUser.blockingGet() + currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) } @@ -631,6 +633,7 @@ class SettingsActivity : } .setNeutralButton(R.string.nc_diagnosis_dialog_open_diagnosis) { _, _ -> val intent = Intent(context, DiagnosisActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(intent) } viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) @@ -656,14 +659,14 @@ class SettingsActivity : private fun setupServerNotificationAppCheck() { val serverNotificationAppInstalled = - currentUserProviderOld.currentUser.blockingGet() + currentUser!! .capabilities?.notificationsCapability?.features?.isNotEmpty() ?: false if (!serverNotificationAppInstalled) { binding.settingsServerNotificationAppWrapper.visibility = View.VISIBLE val description = context.getString(R.string.nc_settings_contact_admin_of) + LINEBREAK + - currentUserProviderOld.currentUser.blockingGet().baseUrl!! + currentUser!!.baseUrl!! binding.settingsServerNotificationAppDescription.text = description if (openedByNotificationWarning) { @@ -692,7 +695,9 @@ class SettingsActivity : private fun setupDiagnosis() { binding.diagnosisWrapper.setOnClickListener { - startActivity(Intent(context, DiagnosisActivity::class.java)) + val intent = Intent(context, DiagnosisActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id) + startActivity(intent) } binding.logsWrapper.setOnClickListener { startActivity(Intent(context, LogsActivity::class.java)) From 060ef4fd91c490ae8a595a3a6f8a148f556d28b1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 18:38:14 +0200 Subject: [PATCH 10/76] fix(theming): theme screens with the server colors of their account Screens were themed with the server colors of the active account, so a screen of another account (e.g. an incoming call for it) used the wrong colors. - ViewThemeUtilsFactory creates ViewThemeUtils for a given account - Activities bound to an account apply its theme right after injection (BaseActivity.applyUserTheme) - Fragments, dialogs and views use the ViewThemeUtils of their hosting activity (hostViewThemeUtils) Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 12 ++++++ .../nextcloud/talk/activities/CallActivity.kt | 1 + .../FileAttachmentPreviewFragment.kt | 2 + .../CallNotificationActivity.kt | 1 + .../com/nextcloud/talk/chat/ChatActivity.kt | 1 + .../talk/chat/MessageInputFragment.kt | 2 + .../MessageInputVoiceRecordingFragment.kt | 2 + .../talk/chat/ScheduledMessagesActivity.kt | 1 + .../talk/contacts/ContactsActivity.kt | 1 + .../RenameConversationDialogFragment.kt | 2 + .../ConversationCreationActivity.kt | 1 + .../ConversationInfoActivity.kt | 1 + .../ConversationInfoEditActivity.kt | 1 + .../ConversationsListActivity.kt | 1 + .../talk/diagnosis/DiagnosisActivity.kt | 1 + .../talk/invitation/InvitationsActivity.kt | 1 + .../invitation/adapters/InvitationsAdapter.kt | 2 + .../talk/location/LocationPickerActivity.kt | 1 + .../activities/MediaViewerActivity.kt | 1 + .../messagesearch/MessageSearchActivity.kt | 1 + .../ListOpenConversationsActivity.kt | 1 + .../talk/polls/ui/PollCreateDialogFragment.kt | 2 + .../talk/polls/ui/PollLoadingFragment.kt | 2 + .../talk/polls/ui/PollMainDialogFragment.kt | 2 + .../talk/polls/ui/PollResultsFragment.kt | 2 + .../talk/polls/ui/PollVoteFragment.kt | 2 + .../MentionAutocompletePresenter.java | 2 + .../nextcloud/talk/profile/ProfileActivity.kt | 1 + .../activities/RemoteFileBrowserActivity.kt | 24 +++++++---- .../talk/settings/SettingsActivity.kt | 1 + .../activities/SharedItemsActivity.kt | 1 + .../ThreadsOverviewActivity.kt | 1 + .../talk/translate/ui/TranslateActivity.kt | 1 + .../nextcloud/talk/ui/PlaybackSpeedControl.kt | 2 + .../talk/ui/dialog/AttachmentDialog.kt | 2 + .../talk/ui/dialog/AudioOutputDialog.kt | 2 + .../talk/ui/dialog/DateTimeCompose.kt | 3 +- .../talk/ui/dialog/DialogBanListFragment.kt | 2 + .../ui/dialog/FilterConversationFragment.kt | 2 + .../talk/ui/dialog/MoreCallActionsDialog.kt | 2 + .../ui/dialog/SaveToStorageDialogFragment.kt | 2 + .../ui/dialog/SetPhoneNumberDialogFragment.kt | 2 + .../talk/ui/theme/HostViewThemeUtils.kt | 28 +++++++++++++ .../talk/ui/theme/ViewThemeUtilsFactory.kt | 41 +++++++++++++++++++ 44 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index e43567dcd8b..d36cde616d2 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -43,6 +43,7 @@ import com.nextcloud.talk.events.RemoteWipeEvent import com.nextcloud.talk.lock.LockedActivity import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileViewerUtils import com.nextcloud.talk.utils.UriUtils @@ -80,6 +81,9 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var viewThemeUtilsFactory: ViewThemeUtilsFactory + @Inject lateinit var messageUtils: MessageUtils @@ -107,6 +111,14 @@ open class BaseActivity : AppCompatActivity() { intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) } + /** + * Themes this activity, and the fragments and dialogs it hosts, with the server colors of the account it was + * started for (see [resolveUserIdFromIntent]). Must be called after injection and before any view is themed. + */ + protected fun applyUserTheme() { + viewThemeUtilsFactory.forUserId(resolveUserIdFromIntent())?.let { viewThemeUtils = it } + } + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 737ce78fe9d..473f09392d1 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -447,6 +447,7 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "onCreate") super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + applyUserTheme() // Register broadcast receiver for ending call from notification val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt index dbcfaadb533..42677896a8c 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -25,6 +25,7 @@ import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.preferences.AppPreferences import javax.inject.Inject @@ -101,6 +102,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel.setInitialFiles(filesList) diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 62936ab2581..b71e8cd014f 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -73,6 +73,7 @@ class CallNotificationActivity : CallBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + applyUserTheme() binding = CallNotificationActivityBinding.inflate(layoutInflater) setContentView(binding!!.root) hideNavigationIfNoPipAvailable() diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 5f85951daef..e1c31069214 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -599,6 +599,7 @@ class ChatActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 8270bf5da40..11fb8297af0 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -76,6 +76,7 @@ import com.nextcloud.talk.ui.CallStartedBanner import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -137,6 +138,7 @@ class MessageInputFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) conversationInternalId = arguments?.getString(ChatActivity.CONVERSATION_INTERNAL_ID).orEmpty() chatActivity = requireActivity() as ChatActivity val sharedText = arguments?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt index a6e1e2f9572..6832cfcf70e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -27,6 +27,7 @@ import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -54,6 +55,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index 605549f6b6b..c1a1d9069ae 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -162,6 +162,7 @@ class ScheduledMessagesActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() scheduledMessagesViewModel = ViewModelProvider(this, viewModelFactory)[ScheduledMessagesViewModel::class.java] setContent { diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index 98ac2b9af4c..3d0dc6b99cc 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -43,6 +43,7 @@ class ContactsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } if (user == null) { finish() diff --git a/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt index b2c872b3f84..16c6d8ea6b0 100644 --- a/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt @@ -32,6 +32,7 @@ import com.nextcloud.talk.databinding.DialogRenameConversationBinding import com.nextcloud.talk.events.ConversationsListFetchDataEvent import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import javax.inject.Inject @@ -61,6 +62,7 @@ class RenameConversationDialogFragment : DialogFragment() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 0edc807dd8a..830201e1e1f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -128,6 +128,7 @@ class ConversationCreationActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val userId = resolveUserIdFromIntent() val conversationCreationViewModel = ViewModelProvider( this, diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index f168a3cf0c8..4fda75aaaeb 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -182,6 +182,7 @@ class ConversationInfoActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() conversationToken = requireNotNull( intent.getStringExtra(KEY_ROOM_TOKEN) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index a1d79d136c4..cb5528bfe2e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -80,6 +80,7 @@ class ConversationInfoEditActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val roomToken = intent.extras?.getString(BundleKeys.KEY_ROOM_TOKEN)!! val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 32faa93a438..9f86d5fc982 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -203,6 +203,7 @@ class ConversationsListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() ecosystemManager = EcosystemManager(this@ConversationsListActivity) currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index d630be2a537..d697daea5f5 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -95,6 +95,7 @@ class DiagnosisActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } if (user == null) { finish() diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index 1c345affa1e..597885a99f7 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -62,6 +62,7 @@ class InvitationsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() invitationsViewModel = ViewModelProvider(this, viewModelFactory)[InvitationsViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt b/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt index 104ea733529..56c6748664c 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt @@ -19,6 +19,7 @@ import com.nextcloud.talk.databinding.RvItemInvitationBinding import com.nextcloud.talk.invitation.InvitationsActivity import com.nextcloud.talk.invitation.data.Invitation import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -64,6 +65,7 @@ class InvitationsAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InvitationsViewHolder { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(parent.context, viewThemeUtils) return InvitationsViewHolder( RvItemInvitationBinding.inflate( LayoutInflater.from(parent.context), diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index ba13a44011e..c54b67b4ffe 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -74,6 +74,7 @@ class LocationPickerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index add4abc8908..31b0dd3770d 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -60,6 +60,7 @@ class MediaViewerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 4bde9404960..b6f9fb3e6ab 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -71,6 +71,7 @@ class MessageSearchActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() binding = ActivityMessageSearchBinding.inflate(layoutInflater) setupActionBar() diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index 3781d8217a6..8ead76394c3 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -41,6 +41,7 @@ class ListOpenConversationsActivity : BaseActivity() { adjustUIForAPILevel35() super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } if (user == null) { diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt index ecfcacb75eb..24a37ca5f95 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt @@ -32,6 +32,7 @@ import com.nextcloud.talk.polls.adapters.PollCreateOptionsAdapter import com.nextcloud.talk.polls.adapters.PollCreateOptionsItemListener import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -56,6 +57,7 @@ class PollCreateDialogFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollCreateViewModel::class.java] val roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt index 75c65a73959..089082b04c0 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt @@ -16,6 +16,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogPollLoadingBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -29,6 +30,7 @@ class PollLoadingFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt index 1c42701ca84..ab1e69ed201 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogPollMainBinding import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -43,6 +44,7 @@ class PollMainDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollMainViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt index ae925cac651..fce36d32754 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt @@ -25,6 +25,7 @@ import com.nextcloud.talk.polls.adapters.PollResultsAdapter import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -48,6 +49,7 @@ class PollResultsFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollResultsViewModel::class.java] parentViewModel = ViewModelProvider(requireParentFragment(), viewModelFactory)[PollMainViewModel::class.java] } diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt index 413dee9ee02..72bbe32d250 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt @@ -29,6 +29,7 @@ import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -51,6 +52,7 @@ class PollVoteFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollVoteViewModel::class.java] parentViewModel = ViewModelProvider(requireParentFragment(), viewModelFactory)[PollMainViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java b/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java index 8d6d286857e..7b28dff502f 100644 --- a/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java +++ b/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java @@ -20,6 +20,7 @@ import com.nextcloud.talk.data.user.model.User; import com.nextcloud.talk.models.json.mention.MentionDto; import com.nextcloud.talk.models.json.mention.MentionOverall; +import com.nextcloud.talk.ui.theme.HostViewThemeUtils; import com.nextcloud.talk.ui.theme.ViewThemeUtils; import com.nextcloud.talk.users.UserManager; import com.nextcloud.talk.utils.ApiUtils; @@ -68,6 +69,7 @@ public MentionAutocompletePresenter(Context context, User user, String roomToken this.context = context; this.chatApiVersion = chatApiVersion; NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); + viewThemeUtils = HostViewThemeUtils.hostViewThemeUtils(context, viewThemeUtils); } @Override diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index 2cd07031134..b335897befe 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -115,6 +115,7 @@ class ProfileActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val restoredEdit = savedInstanceState?.getBoolean(KEY_EDIT_MODE) ?: false profileUiState = profileUiState.copy(isEditMode = restoredEdit) diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index fd53530b519..3e4d6885d9e 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -37,6 +37,7 @@ import com.nextcloud.talk.remotefilebrowser.adapters.RemoteFileBrowserItemsAdapt import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.ui.dialog.SortingOrderDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils @@ -63,6 +64,9 @@ class RemoteFileBrowserActivity : @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var viewThemeUtilsFactory: ViewThemeUtilsFactory + @Inject lateinit var dateUtils: DateUtils @@ -82,6 +86,17 @@ class RemoteFileBrowserActivity : super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + user = runBlocking { + if (userId != 0L) userManager.getUserWithId(userId) else userManager.getCurrentUser() + } ?: run { + Log.e(TAG, "No user found") + finish() + return + } + + viewThemeUtils = viewThemeUtilsFactory.forUser(user) + binding = ActivityRemoteFileBrowserBinding.inflate(layoutInflater) setSupportActionBar(binding.remoteFileBrowserItemsToolbar) viewThemeUtils.material.themeToolbar(binding.remoteFileBrowserItemsToolbar) @@ -101,15 +116,6 @@ class RemoteFileBrowserActivity : supportActionBar?.setDisplayHomeAsUpEnabled(true) - val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) - user = runBlocking { - if (userId != 0L) userManager.getUserWithId(userId) else userManager.getCurrentUser() - } ?: run { - Log.e(TAG, "No user found") - finish() - return - } - val extras = intent.extras val mimeTypeSelectionFilter = extras?.getString(KEY_MIME_TYPE_FILTER, null) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 9523fa8bde6..98ad5df005f 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -176,6 +176,7 @@ class SettingsActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() networkMonitor.isOnlineLiveData.observe(this) { online -> isOnline.value = online handleNetworkChange(isOnline.value) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 5f35b0bdbc8..3921fcc6e60 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -85,6 +85,7 @@ class SharedItemsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) val user = runBlocking { userManager.getUserWithId(conversationUserId) } diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index 217983b810e..4437a64e918 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -75,6 +75,7 @@ class ThreadsOverviewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } if (user == null) { Log.e(TAG, "No user found") diff --git a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt index 09816320fa8..a74aeb8bdf7 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt @@ -55,6 +55,7 @@ class TranslateActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + applyUserTheme() binding = ActivityTranslateBinding.inflate(layoutInflater) val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt b/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt index 348138e191b..53a69b407ae 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt @@ -11,6 +11,7 @@ import android.content.Context import android.util.AttributeSet import autodagger.AutoInjector import com.google.android.material.button.MaterialButton +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import java.util.Locale import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -35,6 +36,7 @@ class PlaybackSpeedControl @JvmOverloads constructor( init { NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(context, viewThemeUtils) text = currentSpeed.label viewThemeUtils.material.colorMaterialButtonText(this) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt index 1f0cf9512f4..a1cdb1e73c1 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.databinding.DialogAttachmentBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures import javax.inject.Inject @@ -35,6 +36,7 @@ class AttachmentDialog(val activity: Activity, var chatActivity: ChatActivity) : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) dialogAttachmentBinding = DialogAttachmentBinding.inflate(layoutInflater) setContentView(dialogAttachmentBinding.root) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt index 659599fc286..e1eab531dfe 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt @@ -19,6 +19,7 @@ import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogAudioOutputBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.webrtc.WebRtcAudioManager import javax.inject.Inject @@ -33,6 +34,7 @@ class AudioOutputDialog(val callActivity: CallActivity) : BottomSheetDialog(call override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(callActivity, viewThemeUtils) dialogAudioOutputBinding = DialogAudioOutputBinding.inflate(layoutInflater) setContentView(dialogAudioOutputBinding.root) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt index 1a27432c5b3..f2baf8b63c2 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt @@ -66,6 +66,7 @@ import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.dialog.DateTimeCompose.Companion.HALF_WEIGHT import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preview.ComposePreviewUtils import java.time.DayOfWeek @@ -99,7 +100,7 @@ class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel, val return } - val colorScheme = viewThemeUtils.getColorScheme(context) + val colorScheme = hostViewThemeUtils(context, viewThemeUtils).getColorScheme(context) val isCollapsed = remember { mutableStateOf(true) } MaterialTheme(colorScheme = colorScheme) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt index 31249a3f491..7510aa4e079 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt @@ -26,6 +26,7 @@ import com.nextcloud.talk.databinding.BanItemListBinding import com.nextcloud.talk.databinding.FragmentDialogBanListBinding import com.nextcloud.talk.models.json.participants.TalkBanDto import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -82,6 +83,7 @@ class DialogBanListFragment(val roomToken: String, private val conversationUser: override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) binding = FragmentDialogBanListBinding.inflate(layoutInflater) viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt index 0f26daf657b..f5ac1f34fe1 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogFilterConversationBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils @@ -55,6 +56,7 @@ class FilterConversationFragment : DialogFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) setUpColors() setUpListeners() return inflater.inflate(R.layout.dialog_filter_conversation, container, false) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt index bfc10a712be..453212ffef2 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.camera.ImageSegmenterHelper import com.nextcloud.talk.databinding.DialogMoreCallActionsBinding import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.viewmodels.CallRecordingViewModel @@ -40,6 +41,7 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(callActivity, viewThemeUtils) binding = DialogMoreCallActionsBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt index 3c43eb51966..e68d91fedc9 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt @@ -16,6 +16,7 @@ import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -40,6 +41,7 @@ class SaveToStorageDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) fileName = arguments?.getString(KEY_FILE_NAME)!! } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt index e6913c40098..11d4f6e6e86 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt @@ -23,6 +23,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogSetPhoneNumberBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -37,6 +38,7 @@ class SetPhoneNumberDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt new file mode 100644 index 00000000000..68e4ef8ef90 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +@file:JvmName("HostViewThemeUtils") + +package com.nextcloud.talk.ui.theme + +import android.content.Context +import android.content.ContextWrapper +import com.nextcloud.talk.activities.BaseActivity + +/** + * Returns the [ViewThemeUtils] of the [BaseActivity] that [context] belongs to, which is themed for the account that + * activity was started for, or [fallback] if [context] does not belong to a [BaseActivity]. + */ +fun hostViewThemeUtils(context: Context?, fallback: ViewThemeUtils): ViewThemeUtils { + var current = context + while (current is ContextWrapper) { + if (current is BaseActivity) { + return current.viewThemeUtils + } + current = current.baseContext + } + return fallback +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt new file mode 100644 index 00000000000..a4fffa51ff4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui.theme + +import com.nextcloud.android.common.ui.color.ColorUtil +import com.nextcloud.android.common.ui.theme.utils.AndroidViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.DialogViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.MaterialViewThemeUtils +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.users.UserManager +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * Creates [ViewThemeUtils] themed with the server colors of a given account. + */ +class ViewThemeUtilsFactory @Inject constructor( + private val schemesProvider: MaterialSchemesProvider, + private val colorUtil: ColorUtil, + private val userManager: UserManager +) { + /** + * Returns [ViewThemeUtils] for the account with the internal id [userId], or null if there is no such account. + */ + fun forUserId(userId: Long): ViewThemeUtils? = runBlocking { userManager.getUserWithId(userId) }?.let(::forUser) + + fun forUser(user: User): ViewThemeUtils { + val schemes = schemesProvider.getMaterialSchemesForUser(user) + val android = AndroidViewThemeUtils(schemes, colorUtil) + val material = MaterialViewThemeUtils(schemes, colorUtil) + val androidx = AndroidXViewThemeUtils(schemes, android) + val talk = TalkSpecificViewThemeUtils(schemes, androidx) + val dialog = DialogViewThemeUtils(schemes) + return ViewThemeUtils(schemes, android, material, androidx, talk, dialog) + } +} From 5f06ba9805d6e10a1970addf7671496c34537aee Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 19:02:57 +0200 Subject: [PATCH 11/76] refactor(user): replace current user providers by DefaultAccountProvider All screens, workers and receivers now use the account they were started for, so the "current user" providers are only needed where there is no account context (entry points, share-to, account switcher, default theme). Replace CurrentUserProviderOld and CurrentUserProvider by DefaultAccountProvider, which makes that meaning explicit, and rename UserManager.getCurrentUser()/currentUserFlow to getDefaultUser()/defaultUserFlow. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/ui/LoginIT.java | 2 +- .../account/data/io/LocalLoginDataSource.kt | 2 +- .../nextcloud/talk/activities/BaseActivity.kt | 13 ++-- .../nextcloud/talk/activities/MainActivity.kt | 12 ++-- .../ChooseAccountDialogCompose.kt | 6 +- .../viewmodel/StatusMessageViewModel.kt | 6 +- .../viewmodel/StatusViewModel.kt | 6 +- .../ConversationsListActivity.kt | 2 +- .../talk/diagnosis/DiagnosisElement.kt | 2 +- .../talk/jobs/ContactAddressBookWorker.kt | 6 +- .../activities/RemoteFileBrowserActivity.kt | 2 +- .../activities/SharedItemsActivity.kt | 2 +- .../ChooseAccountShareToViewModel.kt | 7 +-- .../talk/ui/theme/MaterialSchemesProvider.kt | 6 +- .../ui/theme/MaterialSchemesProviderImpl.kt | 8 +-- .../nextcloud/talk/ui/theme/ThemeModule.kt | 4 +- .../talk/users/DefaultAccountProvider.kt | 27 +++++++++ .../com/nextcloud/talk/users/UserManager.kt | 8 +-- .../database/user/CurrentUserProvider.kt | 16 ----- .../database/user/CurrentUserProviderImpl.kt | 37 ------------ .../database/user/CurrentUserProviderOld.kt | 16 ----- .../user/CurrentUserProviderOldImpl.kt | 48 --------------- .../talk/utils/database/user/UserModule.kt | 9 --- .../talk/utils/preview/ComposePreviewUtils.kt | 11 ++-- .../nextcloud/talk/utils/ssl/KeyManager.java | 2 +- .../talk/contacts/ContactsViewModelTest.kt | 2 +- .../messagesearch/MessageSearchHelperTest.kt | 26 ++++---- .../talk/users/DefaultAccountProviderTest.kt | 60 +++++++++++++++++++ .../nextcloud/talk/users/UserManagerTest.kt | 26 ++++---- 29 files changed, 166 insertions(+), 208 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt create mode 100644 app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt diff --git a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java index 75cb101dfff..4503a65f6eb 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java +++ b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java @@ -125,7 +125,7 @@ public void login() throws InterruptedException { activityScenario.onActivity(activity -> { assertEquals(loginName, - Objects.requireNonNull(activity.currentUserProviderOld.getCurrentUser().blockingGet()).getUserId()); + Objects.requireNonNull(activity.defaultAccountProvider.getDefaultUserBlocking()).getUserId()); }); } } diff --git a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt index bef3bf3f39f..0534037cc94 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.runBlocking class LocalLoginDataSource(val userManager: UserManager, val appPreferences: AppPreferences, val context: Context) { fun updateUser(loginData: LoginCompletion) { - val currentUser = runBlocking { userManager.getCurrentUser() } + val currentUser = runBlocking { userManager.getDefaultUser() } if (currentUser != null) { currentUser.clientCertificate = appPreferences.temporaryClientCertAlias currentUser.token = loginData.appPassword diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index d36cde616d2..d7e0f835577 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -41,6 +41,7 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.events.CertificateEvent import com.nextcloud.talk.events.RemoteWipeEvent import com.nextcloud.talk.lock.LockedActivity +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory @@ -49,8 +50,6 @@ import com.nextcloud.talk.utils.FileViewerUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProvider -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.logger.Logger @@ -90,12 +89,8 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var context: Context - @Deprecated("Use CurrentUserProvider instead") @Inject - lateinit var currentUserProviderOld: CurrentUserProviderOld - - @Inject - lateinit var currentUserProvider: CurrentUserProvider + lateinit var defaultAccountProvider: DefaultAccountProvider @Inject lateinit var logger: Logger @@ -107,7 +102,7 @@ open class BaseActivity : AppCompatActivity() { */ protected fun resolveUserIdFromIntent(): Long = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L).takeIf { it != 0L } - ?: (currentUserProviderOld.currentUser.blockingGet()?.id ?: 0L).also { + ?: (defaultAccountProvider.getDefaultUserBlocking()?.id ?: 0L).also { intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) } @@ -349,7 +344,7 @@ open class BaseActivity : AppCompatActivity() { } override fun startActivity(intent: Intent) { - val user = currentUserProviderOld.currentUser.blockingGet() + val user = defaultAccountProvider.getDefaultUserBlocking() if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) { val uri = intent.data.toString() if (user?.baseUrl != null && uri.startsWith(user.baseUrl!!)) { diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index f8192aa4237..23d402c621d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -166,7 +166,7 @@ class MainActivity : val baseUrl = userId.substringAfterLast("@") val isValidBaseUrl = baseUrl.isNotBlank() && "https://$baseUrl".toHttpUrlOrNull() != null - val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() if (isValidBaseUrl && currentUser?.baseUrl?.endsWith(baseUrl) == true) { startConversation(user) } else { @@ -187,13 +187,13 @@ class MainActivity : private fun startConversation(userId: String) { val roomType = "1" - val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() ?: return val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, 1)) - val credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token) + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( version = apiVersion, - baseUrl = currentUser?.baseUrl!!, + baseUrl = currentUser.baseUrl!!, roomType = roomType, invite = userId ) @@ -375,7 +375,7 @@ class MainActivity : private fun resolveTargetUser(users: List, deepLinkResult: DeepLinkHandler.DeepLinkResult): User? { val deepLinkHost = deepLinkResult.serverUrl.toUri().host?.lowercase() if (deepLinkHost.isNullOrBlank()) { - return currentUserProviderOld.currentUser.blockingGet() + return defaultAccountProvider.getDefaultUserBlocking() } // Priority: exact match (username + server) > server match > current user fallback @@ -394,7 +394,7 @@ class MainActivity : userHost == deepLinkHost } - val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() val currentUserMatch = currentUser?.takeIf { it.baseUrl?.let { url -> url.toUri().host?.lowercase() } == deepLinkHost } diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index 6ba6cbec76a..a4353205089 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -90,12 +90,12 @@ import com.nextcloud.talk.models.json.status.StatusType import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.launch import java.net.CookieManager import javax.inject.Inject @@ -110,7 +110,7 @@ class ChooseAccountDialogCompose { lateinit var userManager: UserManager @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + lateinit var defaultAccountProvider: DefaultAccountProvider @Inject lateinit var cookieManager: CookieManager @@ -146,7 +146,7 @@ class ChooseAccountDialogCompose { val statusViewState by statusViewModel.statusViewState.collectAsStateWithLifecycle() val invitationsStateByUser by invitationsViewModel.invitationsStateByUser.collectAsStateWithLifecycle() val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val currentUser = currentUserProvider.currentUser.blockingGet()!! + val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! val isStatusAvailable = CapabilitiesUtil.isUserStatusAvailable(currentUser) ecosystemManager = EcosystemManager(activity) diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt index 4a172e73f0c..089eedcb903 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt @@ -14,9 +14,9 @@ import com.nextcloud.talk.chooseaccount.data.StatusRepository import com.nextcloud.talk.models.json.status.ClearAtDto import com.nextcloud.talk.models.json.status.StatusDto import com.nextcloud.talk.models.json.status.predefined.PredefinedStatusDto +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -36,10 +36,10 @@ private const val HTTP_STATUS_CODE_NOT_FOUND = 404 class StatusMessageViewModel @Inject constructor( private val repository: StatusRepository, - private val currentUserProvider: CurrentUserProviderOld + private val defaultAccountProvider: DefaultAccountProvider ) : ViewModel() { - private val currentUser = currentUserProvider.currentUser.blockingGet() + private val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! private val _emoji = MutableStateFlow("") diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt index f1f0bbdb5be..c7a1f7c0a4e 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt @@ -13,8 +13,8 @@ import com.nextcloud.talk.chooseaccount.data.StatusRepository import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.status.StatusOverall import com.nextcloud.talk.models.json.status.StatusType +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -22,11 +22,11 @@ import javax.inject.Inject class StatusViewModel @Inject constructor( private val repository: StatusRepository, - private val currentUserProvider: CurrentUserProviderOld, + private val defaultAccountProvider: DefaultAccountProvider, private val logger: Logger ) : ViewModel() { - private val currentUser = currentUserProvider.currentUser.blockingGet() + private val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) private val _statusViewState = MutableStateFlow(StatusUiState.None) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 9f86d5fc982..47717bce8b3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -207,7 +207,7 @@ class ConversationsListActivity : BaseActivity() { ecosystemManager = EcosystemManager(this@ConversationsListActivity) currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - ?: currentUserProviderOld.currentUser.blockingGet() + ?: defaultAccountProvider.getDefaultUserBlocking() currentUser?.takeIf { !it.current }?.let { user -> // The shown account becomes the last used one, which the account switcher and status views rely on. lifecycleScope.launch { userManager.setUserAsActive(user) } diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt index 16ee9f606c4..c161d9b877a 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt @@ -203,7 +203,7 @@ fun buildDiagnosisElements( // Account try { - val user = accountUser ?: runBlocking { userManager.getCurrentUser() } ?: return data + val user = accountUser ?: runBlocking { userManager.getDefaultUser() } ?: return data addHeadline(context.getString(R.string.nc_diagnosis_account_category_title)) addEntry(context.getString(R.string.nc_diagnosis_account_server), user.baseUrl ?: "") addEntry(context.getString(R.string.nc_diagnosis_account_user_name), user.displayName ?: "") diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt index 70a0666ab42..f1b9c6f006a 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt @@ -34,11 +34,11 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.models.json.search.ContactsByNumberOverall +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ContactUtils import com.nextcloud.talk.utils.DateConstants -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers @@ -59,7 +59,7 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar lateinit var userManager: UserManager @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + lateinit var defaultAccountProvider: DefaultAccountProvider @Inject lateinit var appPreferences: AppPreferences @@ -71,7 +71,7 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar override fun doWork(): Result { sharedApplication!!.componentApplication.inject(this) - val currentUser = currentUserProvider.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() accountName = context.getString(R.string.nc_app_product_name) accountType = BuildConfig.APPLICATION_ID diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index 3e4d6885d9e..1e403cfd610 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -88,7 +88,7 @@ class RemoteFileBrowserActivity : val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) user = runBlocking { - if (userId != 0L) userManager.getUserWithId(userId) else userManager.getCurrentUser() + if (userId != 0L) userManager.getUserWithId(userId) else userManager.getDefaultUser() } ?: run { Log.e(TAG, "No user found") finish() diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 3921fcc6e60..d61bc801384 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -188,7 +188,7 @@ class SharedItemsActivity : BaseActivity() { ) // Context chat Compose integration is pending for this screen. // ContextChatView( - // user = currentUserProviderOld.currentUser.blockingGet(), + // user = user, // context, // viewThemeUtils = viewThemeUtils, // contextChatViewModel diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt index dbd8a25d015..01660597f95 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt @@ -16,21 +16,20 @@ import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersStartStateChooseAccoun import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserErrorStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserSuccessStateChooseAccountShareTo +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import javax.inject.Inject class ChooseAccountShareToViewModel @Inject constructor( private val userManager: UserManager, - currentUserProvider: CurrentUserProvider + defaultAccountProvider: DefaultAccountProvider ) : ViewModel() { - val currentUser: User? = runBlocking { currentUserProvider.getCurrentUser() }.getOrNull() + val currentUser: User? = defaultAccountProvider.getDefaultUserBlocking() private val _chooseAccountShareToViewState: MutableStateFlow = MutableStateFlow(LoadUsersStartStateChooseAccountShareTo) diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt index a1a57ad03c5..93160d7f019 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt @@ -14,5 +14,9 @@ import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto interface MaterialSchemesProvider { fun getMaterialSchemesForUser(user: User?): MaterialSchemes fun getMaterialSchemesForCapabilities(capabilities: CapabilitiesDto?): MaterialSchemes - fun getMaterialSchemesForCurrentUser(): MaterialSchemes + + /** + * Schemes of the default account, for code without an account context of its own. + */ + fun getMaterialSchemesForDefaultUser(): MaterialSchemes } diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt index 34422037464..a911d1c4d83 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt @@ -11,12 +11,12 @@ import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.users.DefaultAccountProvider import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject internal class MaterialSchemesProviderImpl @Inject constructor( - private val userProvider: CurrentUserProviderOld, + private val defaultAccountProvider: DefaultAccountProvider, private val colorUtil: ColorUtil ) : MaterialSchemesProvider { @@ -36,8 +36,8 @@ internal class MaterialSchemesProviderImpl @Inject constructor( return themeCache[url]!! } - override fun getMaterialSchemesForCurrentUser(): MaterialSchemes = - getMaterialSchemesForUser(userProvider.currentUser.blockingGet()) + override fun getMaterialSchemesForDefaultUser(): MaterialSchemes = + getMaterialSchemesForUser(defaultAccountProvider.getDefaultUserBlocking()) override fun getMaterialSchemesForCapabilities(capabilities: CapabilitiesDto?): MaterialSchemes { val serverTheme = ServerThemeImpl(capabilities?.themingCapability, colorUtil) diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt index 296c34c2943..eeaebadd80b 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt @@ -24,7 +24,7 @@ internal abstract class ThemeModule { companion object { @Provides - fun provideCurrentMaterialSchemes(schemesProvider: MaterialSchemesProvider): MaterialSchemes = - schemesProvider.getMaterialSchemesForCurrentUser() + fun provideDefaultMaterialSchemes(schemesProvider: MaterialSchemesProvider): MaterialSchemes = + schemesProvider.getMaterialSchemesForDefaultUser() } } diff --git a/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt b/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt new file mode 100644 index 00000000000..33f285b4c6f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.users + +import com.nextcloud.talk.data.user.model.User +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * Provides the default account, i.e. the last used one. + * + * Only for code without an account context of its own, like app entry points, share-to and the account switcher. + * Screens, workers and receivers must use the account they were started for. + */ +class DefaultAccountProvider @Inject constructor(private val userManager: UserManager) { + + suspend fun getDefaultUser(): User? = userManager.getDefaultUser() + + /** + * Returns the default account without suspending. Only loads it from the database if it is not known yet. + */ + fun getDefaultUserBlocking(): User? = userManager.defaultUserFlow.value ?: runBlocking { getDefaultUser() } +} diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index 7d4b33c96aa..280a5249b33 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -41,7 +41,7 @@ class UserManager internal constructor(private val userRepository: UsersReposito * Only for entry points that have no account context (app launch, share-to, deep links without account). * Screens, workers and receivers must use the user they were started for, see [getUserWithId] and [userFlow]. */ - suspend fun getCurrentUser(): User? = userRepository.getActiveUser() ?: getAnyUserAndSetAsActive() + suspend fun getDefaultUser(): User? = userRepository.getActiveUser() ?: getAnyUserAndSetAsActive() /** * Emits the user with the given internal id whenever its row changes, or null if it does not exist (anymore). @@ -50,7 +50,7 @@ class UserManager internal constructor(private val userRepository: UsersReposito /** * Ensures that at most one user is marked as active. If several are, the one with the highest id is kept, - * matching the user returned by [getCurrentUser]. + * matching the user returned by [getDefaultUser]. */ suspend fun repairMultipleActiveUsers() { if (userRepository.getActiveUsersCount() > 1) { @@ -70,9 +70,9 @@ class UserManager internal constructor(private val userRepository: UsersReposito * Room's own [UsersRepository.getActiveUserFlow] is still relied on underneath to seed this and to catch * any change to the `current` flag that doesn't go through [setUserAsActive]. * - * This is the default account, not the account of a running screen; screens must use [userFlow]. + * Screens must not use this but the account they were started for, see [userFlow]. */ - val currentUserFlow: StateFlow + val defaultUserFlow: StateFlow get() = activeUserStateFlow private val activeUserStateFlow: MutableStateFlow by lazy { diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt deleted file mode 100644 index 965d2387a05..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import kotlinx.coroutines.flow.Flow - -interface CurrentUserProvider { - val currentUserFlow: Flow - suspend fun getCurrentUser(timeout: Long = 5000L): Result -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt deleted file mode 100644 index abc2ee78204..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.users.UserManager -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeoutOrNull -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class CurrentUserProviderImpl @Inject constructor(private val userManager: UserManager) : CurrentUserProvider { - - // only emit non-null users - override val currentUserFlow: Flow = userManager.currentUserFlow.filterNotNull() - - // function for safe one-shot access - override suspend fun getCurrentUser(timeout: Long): Result { - val user = withTimeoutOrNull(timeout) { - currentUserFlow.first() - } - - return if (user != null) { - Result.success(user) - } else { - Result.failure(IllegalStateException("No current user available")) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt deleted file mode 100644 index 1663cad1a89..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import io.reactivex.Maybe - -@Deprecated("Use CurrentUserProvider instead") -interface CurrentUserProviderOld { - val currentUser: Maybe -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt deleted file mode 100644 index 61d1bc5bcf3..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.users.UserManager -import io.reactivex.Maybe -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Listens to changes in the database and provides the current user without needing to query the database everytime. - */ -@Deprecated("Use CurrentUserProvider instead") -@Singleton -class CurrentUserProviderOldImpl @Inject constructor(private val userManager: UserManager) : CurrentUserProviderOld { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - @Volatile - private var _currentUser: User? = null - - init { - scope.launch { - userManager.currentUserFlow.filterNotNull().collect { - _currentUser = it - } - } - } - - @Deprecated("Use currentUserProvider instead") - override val currentUser: Maybe - get() { - val user = _currentUser ?: runBlocking { userManager.getCurrentUser() }?.also { _currentUser = it } - return user?.let { Maybe.just(it) } ?: Maybe.empty() - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt index 07036cc8583..4ff6b378e91 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt @@ -11,7 +11,6 @@ package com.nextcloud.talk.utils.database.user import com.nextcloud.talk.dagger.modules.DatabaseModule import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.users.UserManager -import dagger.Binds import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -19,14 +18,6 @@ import javax.inject.Singleton @Module(includes = [DatabaseModule::class]) abstract class UserModule { - @Binds - abstract fun bindCurrentUserProviderOld( - currentUserProviderOldImpl: CurrentUserProviderOldImpl - ): CurrentUserProviderOld - - @Binds - abstract fun bindCurrentUserProvider(currentUserProviderImpl: CurrentUserProviderImpl): CurrentUserProvider - companion object { @Provides @Singleton diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index 2adf22f7064..bee5dea93c8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -55,9 +55,8 @@ import com.nextcloud.talk.threadsoverview.data.ThreadsRepositoryImpl import com.nextcloud.talk.ui.theme.MaterialSchemesProviderImpl import com.nextcloud.talk.ui.theme.TalkSpecificViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.preferences.AppPreferencesImpl @@ -97,17 +96,17 @@ class ComposePreviewUtils private constructor(context: Context) { val userManager: UserManager get() = UserManager(userRepository) - val userProvider: CurrentUserProviderOld - get() = CurrentUserProviderOldImpl(userManager) + val defaultAccountProvider: DefaultAccountProvider + get() = DefaultAccountProvider(userManager) val currentUser: User - get() = runBlocking { userManager.getCurrentUser() }!! + get() = runBlocking { userManager.getDefaultUser() }!! val colorUtil: ColorUtil get() = ColorUtil(mContext) val materialScheme: MaterialSchemes - get() = MaterialSchemesProviderImpl(userProvider, colorUtil).getMaterialSchemesForCurrentUser() + get() = MaterialSchemesProviderImpl(defaultAccountProvider, colorUtil).getMaterialSchemesForDefaultUser() val viewThemeUtils: ViewThemeUtils get() { diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java b/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java index cf867f74ba9..28791fc0788 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java @@ -56,7 +56,7 @@ public String chooseClientAlias(String[] strings, Principal[] principals, Socket try { currentUser = BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, - (scope, continuation) -> userManager.getCurrentUser(continuation)); + (scope, continuation) -> userManager.getDefaultUser(continuation)); } catch (InterruptedException e) { Log.e(TAG, "Interrupted while getting the current user: " + e.getLocalizedMessage()); Thread.currentThread().interrupt(); diff --git a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt index 57362f88165..cfc738520cd 100644 --- a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt @@ -50,7 +50,7 @@ class ContactsViewModelTest { private var viewModelCount = 0 private fun createViewModel(repo: ContactsRepository): ContactsViewModel { - val user = runBlocking { userManager.getCurrentUser() }!! + val user = runBlocking { userManager.getDefaultUser() }!! return ContactsViewModel(repo, mock(), user).also { viewModelStore.put("contactsViewModel${viewModelCount++}", it) } diff --git a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt index ea64bcc39c5..cd7ce92e3c8 100644 --- a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt @@ -10,13 +10,13 @@ package com.nextcloud.talk.messagesearch import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.test.fakes.FakeUnifiedSearchRepository import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl import com.nextcloud.talk.utils.preview.DummyUserDaoImpl +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Before @@ -36,8 +36,8 @@ class MessageSearchHelperTest { val userManager: UserManager get() = UserManager(userRepository) - val userProvider: CurrentUserProviderOld - get() = CurrentUserProviderOldImpl(userManager) + val currentUser: User + get() = runBlocking { userManager.getDefaultUser() }!! @Suppress("LongParameterList") private fun createMessageEntry( @@ -64,7 +64,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -80,7 +80,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -96,7 +96,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -115,7 +115,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -140,7 +140,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -158,7 +158,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) repeat(5) { @@ -173,7 +173,7 @@ class MessageSearchHelperTest { runTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) Assert.assertEquals(null, sut.loadMore()) } @@ -183,7 +183,7 @@ class MessageSearchHelperTest { runTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val firstPageEntries = (1..5).map { createMessageEntry() } @@ -214,7 +214,7 @@ class MessageSearchHelperTest { runTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val firstPageEntries = listOf( diff --git a/app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt b/app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt new file mode 100644 index 00000000000..19d6edf2308 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.users + +import com.nextcloud.talk.data.user.UsersRepository +import com.nextcloud.talk.data.user.model.User +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +class DefaultAccountProviderTest { + + private val usersRepository: UsersRepository = mock() + private val userManager = UserManager(usersRepository) + private val provider = DefaultAccountProvider(userManager) + + private val userA = User(id = 1, username = "userA", baseUrl = "https://example.com", current = true) + + @Before + fun setUp() { + wheneverBlocking { usersRepository.getActiveUserFlow() }.thenReturn(emptyFlow()) + } + + @Test + fun `getDefaultUserBlocking loads the active user when none is known yet`() { + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(userA) + + assertEquals(userA, provider.getDefaultUserBlocking()) + } + + @Test + fun `getDefaultUserBlocking returns the known default user without querying the database`() = + runTest { + wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(true) + wheneverBlocking { usersRepository.getUserWithId(1L) }.thenReturn(userA) + userManager.setUserAsActive(userA) + + assertEquals(userA, provider.getDefaultUserBlocking()) + verifyBlocking(usersRepository, never()) { getActiveUser() } + } + + @Test + fun `getDefaultUserBlocking is null when there is no account`() { + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(null) + wheneverBlocking { usersRepository.getUsersNotScheduledForDeletion() }.thenReturn(emptyList()) + + assertNull(provider.getDefaultUserBlocking()) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt index 59978b79a40..e283d45bd3b 100644 --- a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt @@ -46,24 +46,24 @@ class UserManagerTest { } @Test - fun `getCurrentUser returns the active user without touching any fallback`() = + fun `getDefaultUser returns the active user without touching any fallback`() = runTest { val active = user(id = 1, username = "userA", baseUrl = "https://example.com", current = true) wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) - val result = userManager.getCurrentUser() + val result = userManager.getDefaultUser() assertEquals(active, result) verifyBlocking(usersRepository, never()) { getUsersNotScheduledForDeletion() } } @Test - fun `getCurrentUser does not write to the database when an active user exists`() = + fun `getDefaultUser does not write to the database when an active user exists`() = runTest { val active = user(id = 1, username = "userA", baseUrl = "https://example.com", current = true) wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) - userManager.getCurrentUser() + userManager.getDefaultUser() verifyBlocking(usersRepository, never()) { setUserAsActiveWithId(any()) } } @@ -104,7 +104,7 @@ class UserManagerTest { } @Test - fun `getCurrentUser falls back to any non-deleted user and sets it active when none is active`() = + fun `getDefaultUser falls back to any non-deleted user and sets it active when none is active`() = runTest { val fallback = user(id = 1, username = "userA", baseUrl = "https://example.com") wheneverBlocking { usersRepository.getUsersNotScheduledForDeletion() }.thenReturn(listOf(fallback)) @@ -113,18 +113,18 @@ class UserManagerTest { // now reporting the freshly-activated row. wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(null, fallback) - val result = userManager.getCurrentUser() + val result = userManager.getDefaultUser() assertEquals(fallback, result) verifyBlocking(usersRepository) { setUserAsActiveWithId(fallback.id!!) } } @Test - fun `getCurrentUser is null when there is no active user and none to fall back to`() = + fun `getDefaultUser is null when there is no active user and none to fall back to`() = runTest { wheneverBlocking { usersRepository.getUsersNotScheduledForDeletion() }.thenReturn(emptyList()) - assertNull(userManager.getCurrentUser()) + assertNull(userManager.getDefaultUser()) } @Test @@ -280,7 +280,7 @@ class UserManagerTest { } @Test - fun `setUserAsActive publishes the new user on currentUserFlow only when it succeeds`() = + fun `setUserAsActive publishes the new user on defaultUserFlow only when it succeeds`() = runTest { val target = user(id = 1, username = "userA", baseUrl = "https://example.com") wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(true) @@ -288,7 +288,7 @@ class UserManagerTest { val result = userManager.setUserAsActive(target) assertTrue(result) - assertEquals(target, userManager.currentUserFlow.value) + assertEquals(target, userManager.defaultUserFlow.value) } @Test @@ -301,11 +301,11 @@ class UserManagerTest { userManager.setUserAsActive(passed) - assertEquals(stored, userManager.currentUserFlow.value) + assertEquals(stored, userManager.defaultUserFlow.value) } @Test - fun `setUserAsActive leaves currentUserFlow untouched when it fails`() = + fun `setUserAsActive leaves defaultUserFlow untouched when it fails`() = runTest { val target = user(id = 1, username = "userA", baseUrl = "https://example.com") wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(false) @@ -313,7 +313,7 @@ class UserManagerTest { val result = userManager.setUserAsActive(target) assertFalse(result) - assertNull(userManager.currentUserFlow.value) + assertNull(userManager.defaultUserFlow.value) } @Test From cd35bd777bed1a329eca808b4f1736e4faf10dcd Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 19:11:20 +0200 Subject: [PATCH 12/76] docs(user): document default account use of the phone book integration Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt | 1 + .../java/com/nextcloud/talk/users/DefaultAccountProvider.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt index f1b9c6f006a..30abc74e850 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt @@ -71,6 +71,7 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar override fun doWork(): Result { sharedApplication!!.componentApplication.inject(this) + // The phone book integration is not bound to an account, so it syncs for the default account. val currentUser = defaultAccountProvider.getDefaultUserBlocking() accountName = context.getString(R.string.nc_app_product_name) diff --git a/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt b/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt index 33f285b4c6f..3e7ca306c7a 100644 --- a/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt +++ b/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt @@ -13,8 +13,8 @@ import javax.inject.Inject /** * Provides the default account, i.e. the last used one. * - * Only for code without an account context of its own, like app entry points, share-to and the account switcher. - * Screens, workers and receivers must use the account they were started for. + * Only for code without an account context of its own, like app entry points, share-to, the account switcher and the + * phone book integration. Screens, workers and receivers must use the account they were started for. */ class DefaultAccountProvider @Inject constructor(private val userManager: UserManager) { From 4ac2f464e99382fde99a20808734b981e243cf38 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 19:16:06 +0200 Subject: [PATCH 13/76] fix(conversationlist): don't show the previous account's conversations after switching The singleton conversations repository kept the observed account in a global state that getRooms() only updated asynchronously, so the room list of a newly opened account first emitted the conversations of the previously shown account. roomListFlow() now takes the account id. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../data/OfflineConversationsRepository.kt | 14 +++++------ .../OfflineFirstConversationsRepository.kt | 25 ++++--------------- .../viewmodels/ConversationsListViewModel.kt | 4 +-- ...onversationListFreshnessIntegrationTest.kt | 18 ++++++++++++- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt index 72d9efe6131..ee32eb0c7d2 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt @@ -16,11 +16,12 @@ import kotlinx.coroutines.flow.Flow interface OfflineConversationsRepository { /** - * Live stream of the observed account's conversations, for use in the conversation list. - * Backed by the local database: it re-emits whenever conversation rows change (room list - * sync, background catch-up, optimistic updates), with unchanged lists deduplicated. + * Live stream of the conversations of the account with the internal id [accountId], for use in + * the conversation list. Backed by the local database: it re-emits whenever conversation rows + * change (room list sync, background catch-up, optimistic updates), with unchanged lists + * deduplicated. */ - val roomListFlow: Flow> + fun roomListFlow(accountId: Long): Flow> /** * Emits when [getRooms] fails to sync with the server (e.g. a dropped/reset connection on a @@ -38,9 +39,8 @@ interface OfflineConversationsRepository { val conversationFlow: Flow /** - * Selects the account observed by [roomListFlow] and synchronizes its conversations with - * the server (when online). The synced changes surface through [roomListFlow], which - * observes the database. + * Synchronizes the conversations of [user] with the server (when online). The synced changes + * surface through [roomListFlow], which observes the database. */ @Deprecated("use observeConversation") fun getRooms(user: User): Job diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index 91738cb1a9b..4f85af9f25b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -32,15 +32,11 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -59,24 +55,15 @@ class OfflineFirstConversationsRepository @Inject constructor( private val context: Context, private val logger: Logger ) : OfflineConversationsRepository { - private val observedAccountId = MutableStateFlow(null) - /** * The conversation list as a live view of the local database — the single source of truth. * Every write to the conversations table (room list sync, background message catch-up, - * optimistic read state, drafts) reaches collectors reactively; [getRooms] only selects the - * account to observe and triggers the background sync, which stays in place as the authority - * and self-healing safeguard. + * optimistic read state, drafts) reaches collectors reactively; [getRooms] only triggers the + * background sync, which stays in place as the authority and self-healing safeguard. */ - @OptIn(ExperimentalCoroutinesApi::class) - override val roomListFlow: Flow> = - observedAccountId - .filterNotNull() - .distinctUntilChanged() - .flatMapLatest { accountId -> - dao.getConversationsForUser(accountId) - .map { entities -> entities.map(ConversationEntity::toDomainModel) } - } + override fun roomListFlow(accountId: Long): Flow> = + dao.getConversationsForUser(accountId) + .map { entities -> entities.map(ConversationEntity::toDomainModel) } .distinctUntilChanged() override val conversationFlow: Flow @@ -109,8 +96,6 @@ class OfflineFirstConversationsRepository @Inject constructor( override fun getRooms(user: User): Job = scope.launch { - observedAccountId.value = user.id!! - if (networkMonitor.isOnline.value) { getRoomsFromServer(user) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index a46d310eabf..fab1a11962e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -144,7 +144,7 @@ class ConversationsListViewModel @AssistedInject constructor( val getRoomsViewState: LiveData get() = _getRoomsViewState - val getRoomsFlow = repository.roomListFlow + val getRoomsFlow = repository.roomListFlow(currentUser.id!!) .onEach { list -> _getRoomsViewState.value = GetRoomsSuccessState(list.isNotEmpty()) }.catch { @@ -166,7 +166,7 @@ class ConversationsListViewModel @AssistedInject constructor( val isLoadingRooms: StateFlow = _isLoadingRooms.asStateFlow() val getRoomsStateFlow = repository - .roomListFlow + .roomListFlow(currentUser.id!!) .catch { throwable -> Log.e(TAG, "Error observing the conversation list", throwable) _getRoomsViewState.value = GetRoomsErrorState(throwable) diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt index 99fc651faef..c3a1254e98a 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt @@ -379,7 +379,7 @@ class ConversationListFreshnessIntegrationTest { runBlocking { val emissions = mutableListOf>() val collector = launch(Dispatchers.IO) { - repository.roomListFlow.collect { emissions.add(it) } + repository.roomListFlow(user.id!!).collect { emissions.add(it) } } repository.getRooms(user).join() @@ -392,6 +392,21 @@ class ConversationListFreshnessIntegrationTest { } } + @Test + fun `room list flow only emits the conversations of the requested account`() { + val repository = repository() + + runBlocking { + db.usersDao().saveUser( + UserEntity(id = OTHER_ACCOUNT_ID, userId = "other", username = "other", baseUrl = BASE_URL) + ) + seedConversation(lastActivity = 10, lastReadMessage = 1, unreadMessages = 0) + + assertEquals(1, repository.roomListFlow(ACCOUNT_ID).first().size) + assertTrue(repository.roomListFlow(OTHER_ACCOUNT_ID).first().isEmpty()) + } + } + private fun repository(): OfflineFirstConversationsRepository = OfflineFirstConversationsRepository( db.conversationsDao(), @@ -493,6 +508,7 @@ class ConversationListFreshnessIntegrationTest { companion object { private const val ACCOUNT_ID = 1L + private const val OTHER_ACCOUNT_ID = 2L private const val BASE_URL = "https://server.example.com" private const val ROOM_TOKEN = "room1" private const val INTERNAL_CONVERSATION_ID = "$ACCOUNT_ID@$ROOM_TOKEN" From 6291c4c89cd88302e9e8fafc074a3ffccc20e121 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 19:19:43 +0200 Subject: [PATCH 14/76] fix(user): don't open a second task when switching accounts FLAG_ACTIVITY_NEW_TASK never matches an existing task because the app's activities have an empty task affinity, so FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK started a second app instance. Use FLAG_ACTIVITY_CLEAR_TOP instead, which recreates the conversation list of the current task for the new account and closes all screens above it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ConversationsListActivity.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 47717bce8b3..23deaa46391 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1609,13 +1609,17 @@ class ConversationsListActivity : BaseActivity() { private val TAG = ConversationsListActivity::class.java.simpleName /** - * Creates an intent that shows the conversation list of the account with the internal id [userId] in a - * cleared task, so no screen of a previously shown account stays in the back stack. + * Creates an intent that shows the conversation list of the account with the internal id [userId]. + * A conversation list already in the task is recreated for that account and all screens above it are + * closed, so no screen of a previously shown account stays in the back stack. + * + * FLAG_ACTIVITY_NEW_TASK must not be used here: activities have an empty task affinity, so it would start + * a second task instead of reusing the current one. */ fun createAccountSwitchIntent(context: Context, userId: Long): Intent = Intent(context, ConversationsListActivity::class.java).apply { putExtra(KEY_INTERNAL_USER_ID, userId) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } const val BOTTOM_SHEET_DELAY: Long = 2500 From 834b65e3f836f7a90260e3389c2f9a40ec44300c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 21:35:43 +0200 Subject: [PATCH 15/76] refactor(chat): resolve the chat user without a pseudo coroutine The user is loaded synchronously, so wrapping the setup in lifecycleScope.launch and runCatching only hid that onStart()/onResume() rely on it running within onCreate(). Run it sequentially and close the chat if its account doesn't exist anymore. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 101 +++++++++--------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index e1c31069214..b41046d6243 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -644,63 +644,60 @@ class ChatActivity : overflowContainerHeightPx.intValue = binding.chatOverflowContainer.height } - // Resolved synchronously: onStart()/onResume() rely on initData() having run before they are called. - val boundUser = runBlocking { userManager.getUserWithId(conversationUserId) } - lifecycleScope.launch { - runCatching { checkNotNull(boundUser) { "User not found" } } - .onSuccess { user -> - conversationUser = user - handleIntent(intent) - val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) - val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) - - chatViewModel.initData( - user, - credentials!!, - urlForChatting, - conversationThreadId - ) + // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), + // and registerForActivityResult() must be called before the activity is started. + val user = runBlocking { userManager.getUserWithId(conversationUserId) } + if (user == null) { + Log.e(TAG, "No user found for id $conversationUserId") + finish() + return + } + conversationUser = user + handleIntent(intent) + val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, user.baseUrl, roomToken) + val credentials = ApiUtils.getCredentials(user.username, user.token) - conversationThreadId?.let { - val threadUrl = ApiUtils.getUrlForThread( - version = 1, - baseUrl = conversationUser!!.baseUrl, - token = roomToken, - threadId = it.toInt() - ) - chatViewModel.getThread(credentials, threadUrl) - } + chatViewModel.initData( + user, + credentials!!, + urlForChatting, + conversationThreadId + ) - messageInputFragment = getMessageInputFragment() - messageInputViewModel.setData(chatViewModel.getChatRepository()) + conversationThreadId?.let { + val threadUrl = ApiUtils.getUrlForThread( + version = 1, + baseUrl = user.baseUrl, + token = roomToken, + threadId = it.toInt() + ) + chatViewModel.getThread(credentials, threadUrl) + } - initObservers() + messageInputFragment = getMessageInputFragment() + messageInputViewModel.setData(chatViewModel.getChatRepository()) - pendingTargetMessageId?.let { messageId -> - lifecycleScope.launch { - chatViewModel.openMessageFromGlobalSearch( - messageId = messageId, - threadId = pendingTargetThreadId, - searchQuery = pendingTargetSearchQuery - ) - } - pendingTargetMessageId = null - pendingTargetThreadId = null - pendingTargetSearchQuery = null - } + initObservers() - pickMultipleMedia = registerForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER) - ) { uris -> - if (uris.isNotEmpty()) { - onChooseFileResult(uris) - } - } - } - .onFailure { - logger.e(TAG, "Failed to register media picker activity result launcher", it) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } + pendingTargetMessageId?.let { messageId -> + lifecycleScope.launch { + chatViewModel.openMessageFromGlobalSearch( + messageId = messageId, + threadId = pendingTargetThreadId, + searchQuery = pendingTargetSearchQuery + ) + } + pendingTargetMessageId = null + pendingTargetThreadId = null + pendingTargetSearchQuery = null + } + + pickMultipleMedia = registerForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER) + ) { uris -> + if (uris.isNotEmpty()) { + onChooseFileResult(uris) + } } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) From bd5d5083597bced70c92f6b475ad5736b58abba5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 21:56:00 +0200 Subject: [PATCH 16/76] refactor(chat): let ChatViewModel own the user of the chat ChatViewModel received its user twice: as id via assisted injection and as a copy through initData(), which ChatActivity additionally kept for itself. The view model now gets the user via assisted injection and observes it, initData() derives credentials and URL from it, and ChatActivity.conversationUser reads it from the view model, so activity and view model always see the same, current values. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 74 +++++++++---------- .../talk/chat/viewmodels/ChatViewModel.kt | 46 +++++------- .../activities/SharedItemsActivity.kt | 5 +- .../talk/utils/preview/ComposePreviewUtils.kt | 2 +- 4 files changed, 61 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index b41046d6243..f91f197360f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -298,10 +298,16 @@ class ChatActivity : @Inject lateinit var userManager: UserManager + /** + * The user of this chat as loaded in [onCreate], used to create [chatViewModel]. Afterwards use + * [conversationUser], which reflects later changes of the stored user. + */ + private lateinit var initialUser: User + val chatViewModel: ChatViewModel by viewModels { ViewModelFactoryWithParams(ChatViewModel::class.java) { chatViewModelFactory.build( - conversationUserId, + initialUser, roomToken, conversationThreadId ) @@ -380,7 +386,8 @@ class ChatActivity : var openedViaNotification: Boolean = false var conversationThreadInfo: ThreadInfoDto? = null - lateinit var conversationUser: User + val conversationUser: User + get() = chatViewModel.currentUser lateinit var spreedCapabilities: SpreedCapabilityDto var chatApiVersion: Int = 1 private var roomPassword: String = "" @@ -601,6 +608,14 @@ class ChatActivity : NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() + // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), + // and registerForActivityResult() must be called before the activity is started. + initialUser = runBlocking { userManager.getUserWithId(conversationUserId) } ?: run { + Log.e(TAG, "No user found for id $conversationUserId") + finish() + return + } + binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) @@ -644,34 +659,17 @@ class ChatActivity : overflowContainerHeightPx.intValue = binding.chatOverflowContainer.height } - // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), - // and registerForActivityResult() must be called before the activity is started. - val user = runBlocking { userManager.getUserWithId(conversationUserId) } - if (user == null) { - Log.e(TAG, "No user found for id $conversationUserId") - finish() - return - } - conversationUser = user handleIntent(intent) - val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, user.baseUrl, roomToken) - val credentials = ApiUtils.getCredentials(user.username, user.token) - - chatViewModel.initData( - user, - credentials!!, - urlForChatting, - conversationThreadId - ) + chatViewModel.initData() conversationThreadId?.let { val threadUrl = ApiUtils.getUrlForThread( version = 1, - baseUrl = user.baseUrl, + baseUrl = conversationUser.baseUrl, token = roomToken, threadId = it.toInt() ) - chatViewModel.getThread(credentials, threadUrl) + chatViewModel.getThread(conversationUser.getCredentials(), threadUrl) } messageInputFragment = getMessageInputFragment() @@ -990,7 +988,7 @@ class ChatActivity : value = reactionsSheetMessageId?.let { id -> chatViewModel.getMessageById(id).first() } } reactionsSheetMessage?.let { msg -> - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { conversationUser.let { user -> ShowReactionsModalBottomSheet( chatMessage = msg, @@ -1013,7 +1011,7 @@ class ChatActivity : ?.takeIf { it.actorType.equals("users") } ?.let { msg -> val actorId = msg.actorId ?: return@let - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { conversationUser.let { user -> ProfileModalBottomSheet( actorId = actorId, @@ -1047,7 +1045,7 @@ class ChatActivity : onDismiss = { chatViewModel.dismissMessageActions() } ) } else { - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { conversationUser.let { user -> MessageActionsBottomSheet( actionsState = buildMessageActionsState( @@ -1280,7 +1278,7 @@ class ChatActivity : private fun startDirectChat(actorId: String) { lifecycleScope.launch { try { - if (!::conversationUser.isInitialized) return@launch + if (!::initialUser.isInitialized) return@launch val user = conversationUser val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( @@ -1659,7 +1657,7 @@ class ChatActivity : } } - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { conversationUser.let { user -> val credentials = ApiUtils.getCredentials(user.username, user.token) chatViewModel.fetchUpcomingEvent( @@ -2176,7 +2174,7 @@ class ChatActivity : fun updateToolbarState() { val conversation = currentConversation - val user = if (::conversationUser.isInitialized) conversationUser else null + val user = if (::initialUser.isInitialized) conversationUser else null val isOneToOne = isOneToOneConversation() val capabilitiesReady = ::spreedCapabilities.isInitialized @@ -2363,7 +2361,7 @@ class ChatActivity : } private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) { - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { runOnUiThread { val toastInfo = if (currentConversation?.objectType == ConversationEnums.ObjectType.ROOM) { context.resources.getString(R.string.switch_to_main_room) @@ -3005,7 +3003,7 @@ class ChatActivity : @Suppress("Detekt.TooGenericExceptionCaught") private fun cancelNotificationsForCurrentConversation() { - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { if (!TextUtils.isEmpty(roomToken)) { try { NotificationUtils.cancelExistingNotificationsForRoom( @@ -3036,7 +3034,7 @@ class ChatActivity : getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) } - if (::conversationUser.isInitialized && isActivityNotChangingConfigurations() && isNotInCall()) { + if (::initialUser.isInitialized && isActivityNotChangingConfigurations() && isNotInCall()) { if (isLeavingRoom) { Log.d(TAG, "not leaving room (leave already in progress)") } else if (validSessionId()) { @@ -3085,7 +3083,9 @@ class ChatActivity : super.onDestroy() logConversationInfos("onDestroy") - chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) + if (::initialUser.isInitialized) { + chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) + } findViewById(R.id.toolbar)?.setOnClickListener(null) @@ -3134,7 +3134,7 @@ class ChatActivity : var apiVersion = 1 // FIXME Fix API checking with guests? - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) } @@ -3152,7 +3152,7 @@ class ChatActivity : } private fun setupWebsocket() { - if (currentConversation == null || !::conversationUser.isInitialized) { + if (currentConversation == null || !::initialUser.isInitialized) { Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") return } @@ -3522,7 +3522,7 @@ class ChatActivity : private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { currentConversation?.let { - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(spreedCapabilities)) { Snackbar.make(binding.root, R.string.nc_call_e2ee_not_supported, Snackbar.LENGTH_LONG).show() return @@ -3654,7 +3654,7 @@ class ChatActivity : } else { var apiVersion = 1 // FIXME Fix API checking with guests? - if (::conversationUser.isInitialized) { + if (::initialUser.isInitialized) { apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) } @@ -4101,7 +4101,7 @@ class ChatActivity : } fun userAllowedByPrivilages(message: ChatMessage): Boolean { - if (!::conversationUser.isInitialized) return false + if (!::initialUser.isInitialized) return false val isUserAllowedByPrivileges = if (message.actorId == conversationUser!!.userId) { true diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index c72e120f803..35d03c8dd4f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -274,7 +274,7 @@ class ChatViewModel @AssistedInject constructor( private val audioFocusRequestManager: AudioFocusRequestManager, private val userManager: UserManager, @ApplicationScope private val appScope: CoroutineScope, - @Assisted private val internalUserId: Long, + @Assisted private val initialUser: User, @Assisted private val chatRoomToken: String, @Assisted private val conversationThreadId: Long? ) : ViewModel(), @@ -318,8 +318,11 @@ class ChatViewModel @AssistedInject constructor( get() = results.getOrNull(selectedIndex) } - @Deprecated("use currentUserFlow") - lateinit var currentUser: User + /** + * The user of this chat with its latest stored values. + */ + val currentUser: User + get() = currentUserFlow.value ?: initialUser private var messageSearchHelper: MessageSearchHelper? = null private var searchRequestJob: Job? = null @@ -681,8 +684,8 @@ class ChatViewModel @AssistedInject constructor( // Current user flows // ------------------------------ private val currentUserFlow: StateFlow = - userManager.userFlow(internalUserId) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + userManager.userFlow(initialUser.id!!) + .stateIn(viewModelScope, SharingStarted.Eagerly, initialUser) private val nonNullUserFlow = currentUserFlow.filterNotNull() @@ -1724,7 +1727,7 @@ class ChatViewModel @AssistedInject constructor( * - those get their avatar drawn on the client instead, see [CharacterAvatarUtils]. */ fun getAvatarUrl(message: ChatMessage): String = - if (this::currentUser.isInitialized && !message.hasClientSideAvatar()) { + if (!message.hasClientSideAvatar()) { ApiUtils.getUrlForAvatar( currentUser.baseUrl, message.actorId, @@ -1737,15 +1740,13 @@ class ChatViewModel @AssistedInject constructor( private fun ChatMessage.hasClientSideAvatar(): Boolean = CharacterAvatarUtils.avatarFor(actorType, actorId, actorDisplayName, guestLabel = null) != null - fun initData(user: User, credentials: String, urlForChatting: String, threadId: Long?) { - currentUser = user - + fun initData() { chatRepository.initData( - user, - credentials, - urlForChatting, + currentUser, + ApiUtils.getCredentials(currentUser.username, currentUser.token)!!, + ApiUtils.getUrlForChat(ApiUtils.API_V1, currentUser.baseUrl, chatRoomToken), chatRoomToken, - threadId + conversationThreadId ) observeConversationAndUserFirstTime() @@ -2102,10 +2103,6 @@ class ChatViewModel @AssistedInject constructor( * simply leaving the chat, would immediately mark everything read again. */ fun markChatAsUnread(lastReadMessage: Int) { - if (!this::currentUser.isInitialized) { - return - } - keepMarkedAsUnread = true localLastReadMessage = lastReadMessage resetUnreadMarkerCache() @@ -2123,9 +2120,6 @@ class ChatViewModel @AssistedInject constructor( } fun setChatReadMessage(lastReadMessage: Int) { - if (!this::currentUser.isInitialized) { - return - } // marking as read is the explicit counterpart of marking as unread and hands the read marker // back to the automatic handling keepMarkedAsUnread = false @@ -2376,7 +2370,7 @@ class ChatViewModel @AssistedInject constructor( val internalConversationId = "${currentUser.id}@$chatRoomToken" val workerId = UploadAndShareFilesWorker.upload( - userId = internalUserId, + userId = currentUser.id!!, fileUri = fileUri, roomToken = room, conversationName = displayName, @@ -2461,7 +2455,7 @@ class ChatViewModel @AssistedInject constructor( fun getMessageById(messageId: Long): Flow { val urlForChatting = ApiUtils.getUrlForChat( 1, // Keep API v1 for local message lookup until version wiring is centralized. - currentUser?.baseUrl, + currentUser.baseUrl, chatRoomToken ) @@ -2599,9 +2593,8 @@ class ChatViewModel @AssistedInject constructor( } } - suspend fun fetchOpenGraph(url: String): OpenGraphObjectDto? { - if (!this::currentUser.isInitialized) return null - return withContext(Dispatchers.IO) { + suspend fun fetchOpenGraph(url: String): OpenGraphObjectDto? = + withContext(Dispatchers.IO) { runCatching { chatNetworkDataSource.getOpenGraph( currentUser.getCredentials(), @@ -2610,7 +2603,6 @@ class ChatViewModel @AssistedInject constructor( )?.openGraphObject }.getOrNull() } - } suspend fun updateMessageDraft() { val model = conversationRepository.getLocallyStoredConversation( @@ -2833,6 +2825,6 @@ class ChatViewModel @AssistedInject constructor( @AssistedFactory interface ChatViewModelFactory { - fun build(internalUserId: Long, roomToken: String, conversationThreadId: Long?): ChatViewModel + fun build(user: User, roomToken: String, conversationThreadId: Long?): ChatViewModel } } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index d61bc801384..15bfc657f89 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -53,6 +53,8 @@ class SharedItemsActivity : BaseActivity() { private val conversationUserId: Long by lazy { resolveUserIdFromIntent() } + private lateinit var conversationUser: User + val roomToken: String by lazy { intent.getStringExtra(KEY_ROOM_TOKEN) ?: error("roomToken missing") @@ -69,7 +71,7 @@ class SharedItemsActivity : BaseActivity() { val chatViewModel: ChatViewModel by viewModels { ViewModelFactoryWithParams(ChatViewModel::class.java) { chatViewModelFactory.build( - conversationUserId, + conversationUser, roomToken, conversationThreadId ) @@ -94,6 +96,7 @@ class SharedItemsActivity : BaseActivity() { finish() return } + conversationUser = user val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) val isOne2One = intent.getBooleanExtra(KEY_IS_ONE_2_ONE, false) diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index bee5dea93c8..f5b3b3ca4d8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -236,7 +236,7 @@ class ComposePreviewUtils private constructor(context: Context) { audioFocusRequestManager = audioFocusRequestManager, userManager = userManager, appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), - internalUserId = 1L, + initialUser = currentUser, chatRoomToken = "", conversationThreadId = null, logger = TestLogger From 64eda507f8eb159b2b858cd63bf9f856151f39d8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 22:05:47 +0200 Subject: [PATCH 17/76] refactor(chat): remove always-true user checks in ChatActivity The user of the chat is loaded at the start of onCreate() and the chat is closed if it doesn't exist, so it is always available afterwards. Only onDestroy() still checks it, as it is also called in that case. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 285 ++++++++---------- 1 file changed, 128 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index f91f197360f..bd619ad0eeb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -988,19 +988,15 @@ class ChatActivity : value = reactionsSheetMessageId?.let { id -> chatViewModel.getMessageById(id).first() } } reactionsSheetMessage?.let { msg -> - if (::initialUser.isInitialized) { - conversationUser.let { user -> - ShowReactionsModalBottomSheet( - chatMessage = msg, - user = user, - roomToken = roomToken, - hasReactPermission = participantPermissions?.hasReactPermission() == true, - ncApiCoroutines = ncApiCoroutines, - onDeleteReaction = { emoji -> chatViewModel.deleteReaction(roomToken, msg, emoji) }, - onDismiss = { chatViewModel.dismissReactionsSheet() } - ) - } - } + ShowReactionsModalBottomSheet( + chatMessage = msg, + user = conversationUser, + roomToken = roomToken, + hasReactPermission = participantPermissions?.hasReactPermission() == true, + ncApiCoroutines = ncApiCoroutines, + onDeleteReaction = { emoji -> chatViewModel.deleteReaction(roomToken, msg, emoji) }, + onDismiss = { chatViewModel.dismissReactionsSheet() } + ) } val profileSheetMessageId by chatViewModel.profileSheetMessageId.collectAsStateWithLifecycle() @@ -1011,17 +1007,13 @@ class ChatActivity : ?.takeIf { it.actorType.equals("users") } ?.let { msg -> val actorId = msg.actorId ?: return@let - if (::initialUser.isInitialized) { - conversationUser.let { user -> - ProfileModalBottomSheet( - actorId = actorId, - user = user, - ncApiCoroutines = ncApiCoroutines, - onTalkTo = { actorId -> startDirectChat(actorId) }, - onDismiss = { chatViewModel.dismissProfileSheet() } - ) - } - } + ProfileModalBottomSheet( + actorId = actorId, + user = conversationUser, + ncApiCoroutines = ncApiCoroutines, + onTalkTo = { actorId -> startDirectChat(actorId) }, + onDismiss = { chatViewModel.dismissProfileSheet() } + ) } val messageActionsMessageId by chatViewModel.messageActionsMessageId.collectAsStateWithLifecycle() @@ -1045,62 +1037,58 @@ class ChatActivity : onDismiss = { chatViewModel.dismissMessageActions() } ) } else { - if (::initialUser.isInitialized) { - conversationUser.let { user -> - MessageActionsBottomSheet( - actionsState = buildMessageActionsState( - message = msg, - user = user, - conversation = currentConversation, - hasChatPermission = participantPermissions?.hasChatPermission() == true, - hasReactPermission = participantPermissions?.hasReactPermission() == true, - spreedCapabilities = spreedCapabilities, - isOnline = isOnline, - dateUtils = dateUtils, - conversationThreadId = conversationThreadId - ), - onEmojiClick = { emoji -> - if (msg.reactionsSelf?.contains(emoji) == true) { - chatViewModel.deleteReaction(roomToken, msg, emoji) - } else { - chatViewModel.addReaction(roomToken, msg, emoji) - } - }, - onReply = { - if (msg.isThread && conversationThreadId == null) { - openThread(msg) - } else { - messageInputViewModel.reply(msg) - } - }, - onReplyPrivately = { replyPrivately(msg) }, - onOpenThread = { msg.threadId?.let { openThread(it) } }, - onForward = { forwardMessage(msg) }, - onEdit = { messageInputViewModel.edit(msg) }, - onCopy = { copyMessage(msg) }, - onCopyMessageLink = { copyMessageLink(msg) }, - onMarkAsUnread = { markAsUnread(msg) }, - onRemind = { remindMeLater(msg) }, - onPin = { pinMessage(msg) }, - onUnpin = { unPinMessage(msg) }, - onTranslate = { translateMessage(msg) }, - onShareToNote = { shareToNotes(msg) }, - onShare = { - if (msg.getCalculateMessageType() == - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE - ) { - checkIfSharable(msg) - } else { - msg.message?.let { shareMessageText(it) } - } - }, - onSave = { checkIfSaveable(msg) }, - onOpenInFiles = { openInFilesApp(msg) }, - onDelete = { deleteMessage(msg) }, - onDismiss = { chatViewModel.dismissMessageActions() } - ) - } - } + MessageActionsBottomSheet( + actionsState = buildMessageActionsState( + message = msg, + user = conversationUser, + conversation = currentConversation, + hasChatPermission = participantPermissions?.hasChatPermission() == true, + hasReactPermission = participantPermissions?.hasReactPermission() == true, + spreedCapabilities = spreedCapabilities, + isOnline = isOnline, + dateUtils = dateUtils, + conversationThreadId = conversationThreadId + ), + onEmojiClick = { emoji -> + if (msg.reactionsSelf?.contains(emoji) == true) { + chatViewModel.deleteReaction(roomToken, msg, emoji) + } else { + chatViewModel.addReaction(roomToken, msg, emoji) + } + }, + onReply = { + if (msg.isThread && conversationThreadId == null) { + openThread(msg) + } else { + messageInputViewModel.reply(msg) + } + }, + onReplyPrivately = { replyPrivately(msg) }, + onOpenThread = { msg.threadId?.let { openThread(it) } }, + onForward = { forwardMessage(msg) }, + onEdit = { messageInputViewModel.edit(msg) }, + onCopy = { copyMessage(msg) }, + onCopyMessageLink = { copyMessageLink(msg) }, + onMarkAsUnread = { markAsUnread(msg) }, + onRemind = { remindMeLater(msg) }, + onPin = { pinMessage(msg) }, + onUnpin = { unPinMessage(msg) }, + onTranslate = { translateMessage(msg) }, + onShareToNote = { shareToNotes(msg) }, + onShare = { + if (msg.getCalculateMessageType() == + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE + ) { + checkIfSharable(msg) + } else { + msg.message?.let { shareMessageText(it) } + } + }, + onSave = { checkIfSaveable(msg) }, + onOpenInFiles = { openInFilesApp(msg) }, + onDelete = { deleteMessage(msg) }, + onDismiss = { chatViewModel.dismissMessageActions() } + ) } } } @@ -1278,7 +1266,6 @@ class ChatActivity : private fun startDirectChat(actorId: String) { lifecycleScope.launch { try { - if (!::initialUser.isInitialized) return@launch val user = conversationUser val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( @@ -1647,25 +1634,23 @@ class ChatActivity : if (state.conversationModel.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && state.conversationModel.status == "dnd" ) { - conversationUser.let { user -> - val credentials = ApiUtils.getCredentials(user.username, user.token) + conversationUser.let { + val credentials = ApiUtils.getCredentials(it.username, it.token) chatViewModel.outOfOfficeStatusOfUser( credentials!!, - user.baseUrl!!, + it.baseUrl!!, state.conversationModel!!.name ) } } - if (::initialUser.isInitialized) { - conversationUser.let { user -> - val credentials = ApiUtils.getCredentials(user.username, user.token) - chatViewModel.fetchUpcomingEvent( - credentials!!, - user.baseUrl!!, - roomToken - ) - } + conversationUser.let { + val credentials = ApiUtils.getCredentials(it.username, it.token) + chatViewModel.fetchUpcomingEvent( + credentials!!, + it.baseUrl!!, + roomToken + ) } if (state.conversationModel.objectType == ConversationEnums.ObjectType.EVENT && @@ -2174,7 +2159,7 @@ class ChatActivity : fun updateToolbarState() { val conversation = currentConversation - val user = if (::initialUser.isInitialized) conversationUser else null + val user = conversationUser val isOneToOne = isOneToOneConversation() val capabilitiesReady = ::spreedCapabilities.isInitialized @@ -2186,11 +2171,11 @@ class ChatActivity : subtitle = buildToolbarSubtitle(conversation), avatarType = buildAvatarType(conversation), avatarUrl = buildAvatarUrl(user, conversation), - credentials = user?.let { ApiUtils.getCredentials(it.username, it.token) }, + credentials = ApiUtils.getCredentials(user.username, user.token), userStatus = if (isOneToOne) conversation?.status else null, showVoiceCall = isCallsEnabled(capabilitiesReady, conversation), showVideoCall = isCallsEnabled(capabilitiesReady, conversation), - titleClickable = user?.userId != "?" && !chatToolbarState.isSearchMode, + titleClickable = user.userId != "?" && !chatToolbarState.isSearchMode, overflowItems = buildOverflowItems(), threadNotificationIcon = buildThreadNotificationIcon(capabilitiesReady), showEventMenu = conversation?.objectType == ConversationEnums.ObjectType.EVENT, @@ -2361,33 +2346,31 @@ class ChatActivity : } private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) { - if (::initialUser.isInitialized) { - runOnUiThread { - val toastInfo = if (currentConversation?.objectType == ConversationEnums.ObjectType.ROOM) { - context.resources.getString(R.string.switch_to_main_room) - } else { - context.resources.getString(R.string.switch_to_breakout_room) - } - // do not replace with snackbar, as it would disappear with the activity switch - Toast.makeText( - context, - toastInfo, - Toast.LENGTH_LONG - ).show() + runOnUiThread { + val toastInfo = if (currentConversation?.objectType == ConversationEnums.ObjectType.ROOM) { + context.resources.getString(R.string.switch_to_main_room) + } else { + context.resources.getString(R.string.switch_to_breakout_room) } + // do not replace with snackbar, as it would disappear with the activity switch + Toast.makeText( + context, + toastInfo, + Toast.LENGTH_LONG + ).show() + } - val bundle = Bundle() + val bundle = Bundle() - if (startCallAfterRoomSwitch) { - bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) - bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) - } + if (startCallAfterRoomSwitch) { + bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) + bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) + } - leaveRoom { - val chatIntent = createIntent(context, conversationUserId, token, bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) - } + leaveRoom { + val chatIntent = createIntent(context, conversationUserId, token, bundle) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) } } @@ -3003,17 +2986,15 @@ class ChatActivity : @Suppress("Detekt.TooGenericExceptionCaught") private fun cancelNotificationsForCurrentConversation() { - if (::initialUser.isInitialized) { - if (!TextUtils.isEmpty(roomToken)) { - try { - NotificationUtils.cancelExistingNotificationsForRoom( - applicationContext, - conversationUser!!, - roomToken - ) - } catch (e: RuntimeException) { - Log.w(TAG, "Cancel notifications for current conversation results with an error.", e) - } + if (!TextUtils.isEmpty(roomToken)) { + try { + NotificationUtils.cancelExistingNotificationsForRoom( + applicationContext, + conversationUser!!, + roomToken + ) + } catch (e: RuntimeException) { + Log.w(TAG, "Cancel notifications for current conversation results with an error.", e) } } } @@ -3034,7 +3015,7 @@ class ChatActivity : getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) } - if (::initialUser.isInitialized && isActivityNotChangingConfigurations() && isNotInCall()) { + if (isActivityNotChangingConfigurations() && isNotInCall()) { if (isLeavingRoom) { Log.d(TAG, "not leaving room (leave already in progress)") } else if (validSessionId()) { @@ -3132,11 +3113,8 @@ class ChatActivity : } sessionIdAfterRoomJoined = "0" - var apiVersion = 1 // FIXME Fix API checking with guests? - if (::initialUser.isInitialized) { - apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - } + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) val startNanoTime = System.nanoTime() Log.d(TAG, "leaveRoom - leaveRoom - calling: $startNanoTime") @@ -3152,8 +3130,8 @@ class ChatActivity : } private fun setupWebsocket() { - if (currentConversation == null || !::initialUser.isInitialized) { - Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") + if (currentConversation == null) { + Log.e(TAG, "setupWebsocket: currentConversation is null") return } @@ -3522,20 +3500,18 @@ class ChatActivity : private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { currentConversation?.let { - if (::initialUser.isInitialized) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(spreedCapabilities)) { - Snackbar.make(binding.root, R.string.nc_call_e2ee_not_supported, Snackbar.LENGTH_LONG).show() - return - } - val pp = ParticipantPermissions(spreedCapabilities, it) - if (!pp.canStartCall() && currentConversation?.hasCall == false) { - Snackbar.make(binding.root, R.string.startCallForbidden, Snackbar.LENGTH_LONG).show() - } else { - ApplicationWideCurrentRoomHolder.getInstance().isDialing = true - val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) - if (callIntent != null) { - startActivity(callIntent) - } + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(spreedCapabilities)) { + Snackbar.make(binding.root, R.string.nc_call_e2ee_not_supported, Snackbar.LENGTH_LONG).show() + return + } + val pp = ParticipantPermissions(spreedCapabilities, it) + if (!pp.canStartCall() && currentConversation?.hasCall == false) { + Snackbar.make(binding.root, R.string.startCallForbidden, Snackbar.LENGTH_LONG).show() + } else { + ApplicationWideCurrentRoomHolder.getInstance().isDialing = true + val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) + if (callIntent != null) { + startActivity(callIntent) } } } @@ -3652,11 +3628,8 @@ class ChatActivity : ) Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } else { - var apiVersion = 1 // FIXME Fix API checking with guests? - if (::initialUser.isInitialized) { - apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) - } + val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) chatViewModel.deleteChatMessages( credentials!!, @@ -4101,8 +4074,6 @@ class ChatActivity : } fun userAllowedByPrivilages(message: ChatMessage): Boolean { - if (!::initialUser.isInitialized) return false - val isUserAllowedByPrivileges = if (message.actorId == conversationUser!!.userId) { true } else { From 1fa77793859361f0b317fa30920a29584ac40440 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 22:20:05 +0200 Subject: [PATCH 18/76] refactor(user): resolve the bound user of activities in BaseActivity Activities bound to an account all repeated the same lookup and null handling, and applyUserTheme() loaded the same user a second time. BaseActivity now provides boundUser (loaded once and shared with applyUserTheme()) and requireBoundUserOrFinish(), and injects UserManager for its subclasses. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../account/AccountVerificationActivity.kt | 3 -- .../talk/account/ServerSelectionActivity.kt | 4 --- .../talk/account/SwitchAccountActivity.kt | 4 --- .../nextcloud/talk/activities/BaseActivity.kt | 30 +++++++++++++++++-- .../nextcloud/talk/activities/CallActivity.kt | 15 ++-------- .../nextcloud/talk/activities/MainActivity.kt | 4 --- .../CallNotificationActivity.kt | 4 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 11 +------ .../talk/contacts/ContactsActivity.kt | 11 +------ .../ConversationCreationActivity.kt | 4 --- .../ConversationInfoActivity.kt | 6 +--- .../ConversationInfoEditActivity.kt | 12 +------- .../ConversationsListActivity.kt | 7 +---- .../talk/diagnosis/DiagnosisActivity.kt | 11 +------ .../talk/invitation/InvitationsActivity.kt | 10 +------ .../talk/location/LocationPickerActivity.kt | 13 +------- .../nextcloud/talk/logger/ui/LogsActivity.kt | 4 --- .../activities/MediaViewerActivity.kt | 7 +---- .../messagesearch/MessageSearchActivity.kt | 10 +------ .../ListOpenConversationsActivity.kt | 11 +------ .../nextcloud/talk/profile/ProfileActivity.kt | 4 --- .../talk/settings/SettingsActivity.kt | 4 --- .../activities/SharedItemsActivity.kt | 12 +------- .../ThreadsOverviewActivity.kt | 13 +------- .../talk/translate/ui/TranslateActivity.kt | 12 +------- .../talk/ui/theme/ViewThemeUtilsFactory.kt | 10 +------ 26 files changed, 44 insertions(+), 192 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index f0ab828a56c..62cdf16a1aa 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -78,9 +78,6 @@ class AccountVerificationActivity : BaseActivity() { @Inject lateinit var ncApiCoroutines: NcApiCoroutines - @Inject - lateinit var userManager: UserManager - @Inject lateinit var cookieManager: CookieManager diff --git a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt index 6244e8482c8..e07e823c153 100644 --- a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt @@ -37,7 +37,6 @@ import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.databinding.ActivityServerSelectionBinding import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall import com.nextcloud.talk.models.json.generic.StatusDto -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.AccountUtils import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -63,9 +62,6 @@ class ServerSelectionActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - @Inject lateinit var networkMonitor: NetworkMonitor diff --git a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt index e3a198dfde3..b7242e848e4 100644 --- a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt @@ -26,7 +26,6 @@ import com.nextcloud.talk.models.ImportAccount import com.nextcloud.talk.models.json.participants.ParticipantDto import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.DirectShareHelper -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.AccountUtils.findAvailableAccountsOnDevice import com.nextcloud.talk.utils.AccountUtils.getInformationFromAccount import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL @@ -45,9 +44,6 @@ import javax.inject.Inject class SwitchAccountActivity : BaseActivity() { private lateinit var binding: ActivitySwitchAccountBinding - @Inject - lateinit var userManager: UserManager - @Inject lateinit var cookieManager: CookieManager diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index d7e0f835577..4a1ff6ed35d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -38,10 +38,12 @@ import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.account.SwitchAccountActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.CertificateEvent import com.nextcloud.talk.events.RemoteWipeEvent import com.nextcloud.talk.lock.LockedActivity import com.nextcloud.talk.users.DefaultAccountProvider +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory @@ -54,6 +56,7 @@ import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.utils.ssl.TrustManager +import kotlinx.coroutines.runBlocking import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -92,6 +95,9 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var defaultAccountProvider: DefaultAccountProvider + @Inject + lateinit var userManager: UserManager + @Inject lateinit var logger: Logger @@ -107,11 +113,29 @@ open class BaseActivity : AppCompatActivity() { } /** - * Themes this activity, and the fragments and dialogs it hosts, with the server colors of the account it was - * started for (see [resolveUserIdFromIntent]). Must be called after injection and before any view is themed. + * The account this activity was started for (see [resolveUserIdFromIntent]), or null if it doesn't exist. + * Loaded once on first access, which must be after injection. + */ + protected val boundUser: User? by lazy { + runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + } + + /** + * Returns [boundUser], or finishes the activity and returns null if the account doesn't exist. + */ + protected fun requireBoundUserOrFinish(): User? = + boundUser ?: run { + Log.e(TAG, "No user found for id ${resolveUserIdFromIntent()}") + finish() + null + } + + /** + * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. + * Must be called after injection and before any view is themed. */ protected fun applyUserTheme() { - viewThemeUtilsFactory.forUserId(resolveUserIdFromIntent())?.let { viewThemeUtils = it } + boundUser?.let { viewThemeUtils = viewThemeUtilsFactory.forUser(it) } } open val appBarLayoutType: AppBarLayoutType diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 473f09392d1..2dbe722edb0 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -117,7 +117,6 @@ import com.nextcloud.talk.signaling.SignalingMessageReceiver.OfferMessageListene import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.ui.dialog.AudioOutputDialog import com.nextcloud.talk.ui.dialog.MoreCallActionsDialog -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability @@ -169,7 +168,6 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import okhttp3.Cache import org.apache.commons.lang3.StringEscapeUtils import org.greenrobot.eventbus.Subscribe @@ -212,10 +210,6 @@ class CallActivity : CallBaseActivity() { @Inject var ncApi: NcApi? = null - @JvmField - @Inject - var userManager: UserManager? = null - @JvmField @Inject var cache: Cache? = null @@ -537,12 +531,7 @@ class CallActivity : CallBaseActivity() { return } processExtras(intent.extras!!) - val user = runBlocking { userManager!!.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - Log.e(TAG, "No user found for call") - finish() - return - } + val user = requireBoundUserOrFinish() ?: return conversationUser = user if (warnAndFinishIfCallEndToEndEncryptionUnsupported()) { @@ -1642,7 +1631,7 @@ class CallActivity : CallBaseActivity() { val userId = conversationUser!!.id!! val server = externalSignalingServer!! CoroutineScope(Dispatchers.IO).launch { - userManager!!.updateExternalSignalingServer(userId, server) + userManager.updateExternalSignalingServer(userId, server) } } else { conversationUser!!.externalSignalingServer = externalSignalingServer diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 23d402c621d..33b60ba9341 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -33,7 +33,6 @@ import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityMainBinding import com.nextcloud.talk.invitation.InvitationsActivity -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.DeepLinkHandler @@ -58,9 +57,6 @@ class MainActivity : @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - // MainActivity only routes to ConversationsListActivity or ChatActivity and is never // visible to the user. The lock check must run in the actual destination activity. override val skipLockCheckOnResume: Boolean = true diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index b71e8cd014f..8c9ad17ec1d 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -28,7 +28,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.CallNotificationActivityBinding import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.models.json.participants.ParticipantDto -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability @@ -54,9 +53,6 @@ class CallNotificationActivity : CallBaseActivity() { @Inject var cache: Cache? = null - @Inject - lateinit var userManager: UserManager - private var roomToken: String? = null private var notificationTimestamp: Int? = null private var displayName: String? = null diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index bd619ad0eeb..918cf854a56 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -179,7 +179,6 @@ import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.LocalMessageUtils import com.nextcloud.talk.ui.theme.LocalOpenGraphFetcher import com.nextcloud.talk.ui.theme.LocalViewThemeUtils -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -242,7 +241,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -295,9 +293,6 @@ class ChatActivity : @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory - @Inject - lateinit var userManager: UserManager - /** * The user of this chat as loaded in [onCreate], used to create [chatViewModel]. Afterwards use * [conversationUser], which reflects later changes of the stored user. @@ -610,11 +605,7 @@ class ChatActivity : // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), // and registerForActivityResult() must be called before the activity is started. - initialUser = runBlocking { userManager.getUserWithId(conversationUserId) } ?: run { - Log.e(TAG, "No user found for id $conversationUserId") - finish() - return - } + initialUser = requireBoundUserOrFinish() ?: return binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index 3d0dc6b99cc..9439497c0e7 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -23,9 +23,7 @@ import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXI import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys -import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -34,9 +32,6 @@ class ContactsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ContactsViewModel.Factory - @Inject - lateinit var userManager: UserManager - private lateinit var contactsViewModel: ContactsViewModel @SuppressLint("UnrememberedMutableState") @@ -44,11 +39,7 @@ class ContactsActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - finish() - return - } + val user = requireBoundUserOrFinish() ?: return contactsViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ContactsViewModel::class.java) { viewModelFactory.build(user) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 830201e1e1f..d05b757e7e6 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -107,7 +107,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.passwordpolicy.PasswordPolicyField import com.nextcloud.talk.passwordpolicy.isPasswordAccepted -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.copyPasswordToClipboard import com.nextcloud.talk.utils.DisplayUtils @@ -121,9 +120,6 @@ class ConversationCreationActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ConversationCreationViewModel.Factory - @Inject - lateinit var userManager: UserManager - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 4fda75aaaeb..cc6f65cae61 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -39,7 +39,6 @@ import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -126,9 +125,6 @@ class ConversationInfoActivity : BaseActivity() { @Inject lateinit var dateUtils: DateUtils - @Inject - lateinit var userManager: UserManager - lateinit var viewModel: ConversationInfoViewModel private lateinit var conversationToken: String @@ -199,7 +195,7 @@ class ConversationInfoActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] lifecycleScope.launch { - runCatching { checkNotNull(userManager.getUserWithId(resolveUserIdFromIntent())) { "User not found" } } + runCatching { checkNotNull(boundUser) { "User not found" } } .onSuccess { user -> conversationUser = user credentials = ApiUtils.getCredentials(user.username, user.token)!! diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index cb5528bfe2e..928f7f3b6e1 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -30,14 +30,12 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditCallbacks import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditScreen import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking @AutoInjector(NextcloudTalkApplication::class) class ConversationInfoEditActivity : BaseActivity() { @@ -45,9 +43,6 @@ class ConversationInfoEditActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var userManager: UserManager - lateinit var conversationInfoEditViewModel: ConversationInfoEditViewModel private var pickImage: PickImage? = null @@ -83,12 +78,7 @@ class ConversationInfoEditActivity : BaseActivity() { applyUserTheme() val roomToken = intent.extras?.getString(BundleKeys.KEY_ROOM_TOKEN)!! - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - Log.e(TAG, "No user found") - finish() - return - } + val user = requireBoundUserOrFinish() ?: return conversationInfoEditViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 23deaa46391..877ee7df1c2 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -81,7 +81,6 @@ import com.nextcloud.talk.ui.dialog.FilterConversationFragment import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.BrandingUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -132,9 +131,6 @@ import javax.inject.Inject @Suppress("LargeClass", "TooManyFunctions", "NestedBlockDepth") class ConversationsListActivity : BaseActivity() { - @Inject - lateinit var userManager: UserManager - @Inject lateinit var ncApiCoroutines: NcApiCoroutines @@ -206,8 +202,7 @@ class ConversationsListActivity : BaseActivity() { applyUserTheme() ecosystemManager = EcosystemManager(this@ConversationsListActivity) - currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - ?: defaultAccountProvider.getDefaultUserBlocking() + currentUser = boundUser ?: defaultAccountProvider.getDefaultUserBlocking() currentUser?.takeIf { !it.current }?.let { user -> // The shown account becomes the last used one, which the account switcher and status views rely on. lifecycleScope.launch { userManager.setUserAsActive(user) } diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index d697daea5f5..e278f207b3d 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -43,13 +43,11 @@ import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.errorhandling.saveLogsAsZip import com.nextcloud.talk.logger.LogsRepository -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -64,9 +62,6 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - @Inject lateinit var networkMonitor: NetworkMonitor @@ -96,11 +91,7 @@ class DiagnosisActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - finish() - return - } + val user = requireBoundUserOrFinish() ?: return diagnosisViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(DiagnosisViewModel::class.java) { viewModelFactory.build(user) } diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index 597885a99f7..5e2f7383ed0 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -26,9 +26,7 @@ import com.nextcloud.talk.invitation.adapters.InvitationsAdapter import com.nextcloud.talk.invitation.data.ActionEnum import com.nextcloud.talk.invitation.data.Invitation import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys -import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -42,9 +40,6 @@ class InvitationsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var userManager: UserManager - lateinit var invitationsViewModel: InvitationsViewModel lateinit var adapter: InvitationsAdapter @@ -66,10 +61,7 @@ class InvitationsActivity : BaseActivity() { invitationsViewModel = ViewModelProvider(this, viewModelFactory)[InvitationsViewModel::class.java] - currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { - finish() - return - } + currentUser = requireBoundUserOrFinish() ?: return invitationsViewModel.fetchInvitations(currentUser) binding = ActivityInvitationsBinding.inflate(layoutInflater) diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index c54b67b4ffe..5a2d14a44ea 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.location import android.content.Intent import android.os.Bundle -import android.util.Log import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -22,11 +21,9 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.extensions.getParcelableExtraProvider import com.nextcloud.talk.location.components.LocationPickerScreen import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_GEOCODING_RESULT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import kotlinx.coroutines.runBlocking import javax.inject.Inject private const val MAP_CENTER_LAT_KEY = "mapCenterLat" @@ -45,9 +42,6 @@ class LocationPickerActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var userManager: UserManager - private lateinit var viewModel: LocationPickerViewModel private lateinit var roomToken: String private var chatApiVersion: Int = 1 @@ -79,12 +73,7 @@ class LocationPickerActivity : BaseActivity() { roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - Log.e(TAG, "No user found") - finish() - return - } + val user = requireBoundUserOrFinish() ?: return viewModel = ViewModelProvider(this, viewModelFactory)[LocationPickerViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt b/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt index 827a9a67e86..f97d175ffe9 100644 --- a/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt @@ -66,7 +66,6 @@ import com.nextcloud.talk.errorhandling.saveLogsAsZip import com.nextcloud.talk.logger.Level import com.nextcloud.talk.logger.LogEntry import com.nextcloud.talk.logger.LogsRepository -import com.nextcloud.talk.users.UserManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -79,9 +78,6 @@ class LogsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var userManager: UserManager - @Inject lateinit var logsRepository: LogsRepository diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 31b0dd3770d..9ce24c2775f 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -31,13 +31,11 @@ import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import kotlinx.coroutines.runBlocking import java.io.File import javax.inject.Inject @@ -51,9 +49,6 @@ class MediaViewerActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var userManager: UserManager - private lateinit var viewModel: MediaViewerViewModel private lateinit var windowInsetsController: WindowInsetsControllerCompat @@ -65,7 +60,7 @@ class MediaViewerActivity : BaseActivity() { val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) val startMessageId = intent.getLongExtra(EXTRA_START_MESSAGE_ID, -1L) - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + val user = boundUser if (roomToken == null || seedItems.isNullOrEmpty() || user == null) { Log.e(TAG, "Missing data to open the media viewer") diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index b6f9fb3e6ab..9dfd3697b44 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -28,7 +28,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityMessageSearchBinding -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView import eu.davidea.flexibleadapter.FlexibleAdapter @@ -38,7 +37,6 @@ import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.runBlocking import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -53,9 +51,6 @@ class MessageSearchActivity : BaseActivity() { private lateinit var user: User - @Inject - lateinit var userManager: UserManager - private lateinit var viewModel: MessageSearchViewModel private var searchViewDisposable: Disposable? = null @@ -79,10 +74,7 @@ class MessageSearchActivity : BaseActivity() { initSystemBars() viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] - user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { - finish() - return - } + user = requireBoundUserOrFinish() ?: return val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! viewModel.initialize(user, roomToken) setupStateObserver() diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index 8ead76394c3..0424d0d241b 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -21,9 +21,7 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.adjustUIForAPILevel35 -import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -32,9 +30,6 @@ class ListOpenConversationsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: OpenConversationsViewModel.Factory - @Inject - lateinit var userManager: UserManager - private lateinit var openConversationsViewModel: OpenConversationsViewModel override fun onCreate(savedInstanceState: Bundle?) { @@ -43,11 +38,7 @@ class ListOpenConversationsActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - finish() - return - } + val user = requireBoundUserOrFinish() ?: return openConversationsViewModel = ViewModelProvider( this, diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index b335897befe..d2dadbe794b 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -36,7 +36,6 @@ import com.nextcloud.talk.models.json.userprofile.UserProfileDataDto import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall import com.nextcloud.talk.models.json.userprofile.UserProfileOverall import com.nextcloud.talk.ui.dialog.ScopeModalBottomSheet -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.Mimetype.IMAGE_JPG @@ -61,9 +60,6 @@ class ProfileActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - private var currentUser: User? = null private var userInfo: UserProfileDataDto? = null private var editableFields = ArrayList() diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 98ad5df005f..d76405c120c 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -81,7 +81,6 @@ import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.userprofile.UserProfileOverall import com.nextcloud.talk.profile.ProfileActivity import com.nextcloud.talk.ui.dialog.SetPhoneNumberDialogFragment -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -135,9 +134,6 @@ class SettingsActivity : @Inject lateinit var ncApiCoroutines: NcApiCoroutines - @Inject - lateinit var userManager: UserManager - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 15bfc657f89..bbbdc034ab9 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -31,11 +31,9 @@ import com.nextcloud.talk.databinding.ActivitySharedItemsBinding import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter import com.nextcloud.talk.shareditems.model.SharedItemType import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID -import kotlinx.coroutines.runBlocking import javax.inject.Inject import kotlin.getValue @@ -48,9 +46,6 @@ class SharedItemsActivity : BaseActivity() { @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory - @Inject - lateinit var userManager: UserManager - private val conversationUserId: Long by lazy { resolveUserIdFromIntent() } private lateinit var conversationUser: User @@ -90,12 +85,7 @@ class SharedItemsActivity : BaseActivity() { applyUserTheme() val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) - val user = runBlocking { userManager.getUserWithId(conversationUserId) } - if (user == null) { - Log.e(TAG, "No user found") - finish() - return - } + val user = requireBoundUserOrFinish() ?: return conversationUser = user val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index 4437a64e918..ff36e7b48c7 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -9,7 +9,6 @@ package com.nextcloud.talk.threadsoverview import android.os.Bundle import android.text.format.DateUtils -import android.util.Log import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -50,9 +49,7 @@ import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.models.json.threads.ThreadInfoDto import com.nextcloud.talk.threadsoverview.components.ThreadRow import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID -import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -64,9 +61,6 @@ class ThreadsOverviewActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - lateinit var threadsOverviewViewModel: ThreadsOverviewViewModel var threadsSourceUrl: String = "" @@ -76,12 +70,7 @@ class ThreadsOverviewActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - Log.e(TAG, "No user found") - finish() - return - } + val user = requireBoundUserOrFinish() ?: return threadsOverviewViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ThreadsOverviewViewModel::class.java) { diff --git a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt index a74aeb8bdf7..b88fa042aec 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt @@ -28,9 +28,7 @@ import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.databinding.ActivityTranslateBinding import com.nextcloud.talk.translate.repositories.model.LanguageDto import com.nextcloud.talk.translate.viewmodels.TranslateViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys -import kotlinx.coroutines.runBlocking import java.util.Locale import javax.inject.Inject @@ -38,9 +36,6 @@ import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class TranslateActivity : BaseActivity() { - @Inject - lateinit var userManager: UserManager - @Inject lateinit var viewModelFactory: TranslateViewModel.Factory @@ -58,12 +53,7 @@ class TranslateActivity : BaseActivity() { applyUserTheme() binding = ActivityTranslateBinding.inflate(layoutInflater) - val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - if (user == null) { - Log.e(TAG, "No user found") - finish() - return - } + val user = requireBoundUserOrFinish() ?: return viewModel = ViewModelProvider( this, ViewModelFactoryWithParams(TranslateViewModel::class.java) { viewModelFactory.build(user) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt index a4fffa51ff4..263e530ee50 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt @@ -12,8 +12,6 @@ import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils import com.nextcloud.android.common.ui.theme.utils.DialogViewThemeUtils import com.nextcloud.android.common.ui.theme.utils.MaterialViewThemeUtils import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.users.UserManager -import kotlinx.coroutines.runBlocking import javax.inject.Inject /** @@ -21,14 +19,8 @@ import javax.inject.Inject */ class ViewThemeUtilsFactory @Inject constructor( private val schemesProvider: MaterialSchemesProvider, - private val colorUtil: ColorUtil, - private val userManager: UserManager + private val colorUtil: ColorUtil ) { - /** - * Returns [ViewThemeUtils] for the account with the internal id [userId], or null if there is no such account. - */ - fun forUserId(userId: Long): ViewThemeUtils? = runBlocking { userManager.getUserWithId(userId) }?.let(::forUser) - fun forUser(user: User): ViewThemeUtils { val schemes = schemesProvider.getMaterialSchemesForUser(user) val android = AndroidViewThemeUtils(schemes, colorUtil) From 9c87b97803c70d4700346ef82c1172030d1ada25 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 22:37:19 +0200 Subject: [PATCH 19/76] fix(user): report activities started without their account A forgotten KEY_INTERNAL_USER_ID silently made a screen use the default account. Only activities that declare allowsDefaultAccount (the conversation list as entry point) may be started without it; for all others resolveUserIdFromIntent() fails in debug builds and logs an error in release builds. Pass the account where it was still missing: back and forward from the chat, after a call, after login and when opening the settings from the account dialog. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../account/AccountVerificationActivity.kt | 2 ++ .../nextcloud/talk/activities/BaseActivity.kt | 32 +++++++++++++++---- .../nextcloud/talk/activities/CallActivity.kt | 2 ++ .../com/nextcloud/talk/chat/ChatActivity.kt | 2 ++ .../ChooseAccountDialogCompose.kt | 5 +-- .../ConversationsListActivity.kt | 3 ++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index 62cdf16a1aa..b2cfa342f98 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -20,6 +20,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -437,6 +438,7 @@ class AccountVerificationActivity : BaseActivity() { ApplicationWideMessageHolder.MessageType.ACCOUNT_WAS_IMPORTED } val intent = Intent(context, ConversationsListActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userToSetAsActive.id) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) } else { diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 4a1ff6ed35d..adfe811d1f7 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -31,6 +31,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.account.AccountVerificationActivity import com.nextcloud.talk.account.BrowserLoginActivity @@ -101,16 +102,33 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var logger: Logger + /** + * Whether this activity may be started without [BundleKeys.KEY_INTERNAL_USER_ID] and then uses the default + * account. Only for entry points without an account context, like the conversation list on app start. For + * all other activities a missing id is a bug. + */ + protected open val allowsDefaultAccount: Boolean = false + /** * Internal id of the account this activity was started for, taken from [BundleKeys.KEY_INTERNAL_USER_ID]. - * Falls back to the default account for launches without it and writes the id back to the intent, so a - * recreated activity stays on the same account. Returns 0 if there is no account at all. + * Without it, the default account is used and its id is written to the intent, so a recreated activity stays + * on the same account. That is only intended for activities with [allowsDefaultAccount]: for all others it + * fails in debug builds and is logged as an error in release builds. Returns 0 if there is no account at all. */ - protected fun resolveUserIdFromIntent(): Long = - intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L).takeIf { it != 0L } - ?: (defaultAccountProvider.getDefaultUserBlocking()?.id ?: 0L).also { - intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) - } + protected fun resolveUserIdFromIntent(): Long { + val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + if (userId != 0L) { + return userId + } + if (!allowsDefaultAccount) { + val message = "${javaClass.simpleName} was started without ${BundleKeys.KEY_INTERNAL_USER_ID}" + check(!BuildConfig.DEBUG) { message } + Log.e(TAG, "$message, using the default account") + } + return (defaultAccountProvider.getDefaultUserBlocking()?.id ?: 0L).also { + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) + } + } /** * The account this activity was started for (see [resolveUserIdFromIntent]), or null if it doesn't exist. diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 2dbe722edb0..fd0a68b2a62 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -132,6 +132,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_WITHOUT_NOTIFICATION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_PASSWORD import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_BREAKOUT_ROOM import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MODIFIED_BASE_URL @@ -2362,6 +2363,7 @@ class CallActivity : CallBaseActivity() { if (primaryTask != null) { val intent = Intent(context, ConversationsListActivity::class.java).apply { + putExtra(KEY_INTERNAL_USER_ID, conversationUser.id) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) } primaryTask.startActivity(context, intent, null) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 918cf854a56..3dce6e09cf6 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -514,6 +514,7 @@ class ChatActivity : onBackPressedDispatcher.onBackPressed() } else { val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) finish() @@ -3659,6 +3660,7 @@ class ChatActivity : val intent = Intent(this, ConversationsListActivity::class.java) intent.putExtras(bundle) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) finish() diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index a4353205089..d3501e8ba90 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -190,7 +190,7 @@ class ChooseAccountDialogCompose { }, onOpenSettingsClick = { shouldDismiss.value = true - openSettings(activity) + openSettings(activity, currentUser) }, onEcosystemFilesClick = { shouldDismiss.value = true @@ -269,8 +269,9 @@ class ChooseAccountDialogCompose { activity.startActivity(intent) } - private fun openSettings(activity: Activity) { + private fun openSettings(activity: Activity, user: User) { val intent = Intent(activity, SettingsActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) activity.startActivity(intent) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 877ee7df1c2..4654aac8703 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -131,6 +131,9 @@ import javax.inject.Inject @Suppress("LargeClass", "TooManyFunctions", "NestedBlockDepth") class ConversationsListActivity : BaseActivity() { + // Started without an account on app start, when sharing from other apps and after login. + override val allowsDefaultAccount = true + @Inject lateinit var ncApiCoroutines: NcApiCoroutines From f3709ee6e2d8ab4398741a0f7eff7dbd0693d378 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 22:52:36 +0200 Subject: [PATCH 20/76] refactor(conversationcreation): pass the user directly to the view model The screen doesn't need live updates of its user, and the user is already known when the view model is created. Pass it directly instead of a flow, which removes the loading state before the user arrives and a second database lookup. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationCreationActivity.kt | 37 ++++---------- .../ConversationCreationViewModel.kt | 36 +++++--------- .../talk/utils/preview/ComposePreviewUtils.kt | 3 +- .../ConversationCreationViewModelTest.kt | 48 ++++--------------- 4 files changed, 32 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index d05b757e7e6..9d63e774b7e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -125,39 +125,26 @@ class ConversationCreationActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() - val userId = resolveUserIdFromIntent() + val user = requireBoundUserOrFinish() ?: return val conversationCreationViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ConversationCreationViewModel::class.java) { - viewModelFactory.build(userManager.userFlow(userId)) + viewModelFactory.build(user) } )[ConversationCreationViewModel::class.java] setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current - val currentUser by conversationCreationViewModel.currentUser.collectAsState() MaterialTheme( colorScheme = colorScheme ) { - val user = currentUser - if (user == null) { - LoadingScreen() - } else { - val pickImage = remember(user) { PickImage(this@ConversationCreationActivity, user) } - ConversationCreationScreen(conversationCreationViewModel, context, pickImage) - } + val pickImage = remember(user) { PickImage(this@ConversationCreationActivity, user) } + ConversationCreationScreen(conversationCreationViewModel, context, pickImage) } } } } -@Composable -private fun LoadingScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - @Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -421,7 +408,7 @@ fun AddParticipants( val intent = Intent(context, ContactsActivity::class.java) intent.putExtra( BundleKeys.KEY_INTERNAL_USER_ID, - conversationCreationViewModel.currentUser.value?.id + conversationCreationViewModel.currentUser.id ) intent.putParcelableArrayListExtra( "selectedParticipants", @@ -441,12 +428,8 @@ fun AddParticipants( } participants.toSet().forEach { participant -> Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - val avatarUser = conversationCreationViewModel.currentUser.value - val imageUri = if (avatarUser == null) { - null - } else { - participant.id?.let { avatarUri(avatarUser, it, DisplayUtils.isDarkModeOn(context)) } - } + val avatarUser = conversationCreationViewModel.currentUser + val imageUri = participant.id?.let { avatarUri(avatarUser, it, DisplayUtils.isDarkModeOn(context)) } val errorPlaceholderImage: Int = R.drawable.account_circle_96dp val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) AsyncImage( @@ -472,7 +455,7 @@ fun AddParticipants( val intent = Intent(context, ContactsActivity::class.java) intent.putExtra( BundleKeys.KEY_INTERNAL_USER_ID, - conversationCreationViewModel.currentUser.value?.id + conversationCreationViewModel.currentUser.id ) intent.putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) intent.putExtra( @@ -810,7 +793,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo onHandled = { conversationCreationViewModel.clearCreationState() } ) - val currentUser by conversationCreationViewModel.currentUser.collectAsState() + val currentUser = conversationCreationViewModel.currentUser ShareCreatedConversation( roomToken = createdPublicConversation, password = password.takeIf { createdWithPassword }, @@ -819,7 +802,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo onDismiss = { roomToken -> createdPublicConversation = null conversationCreationViewModel.clearCreationState() - currentUser?.id?.let { openConversation(context, it, roomToken) } + currentUser.id?.let { openConversation(context, it, roomToken) } } ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index 60c2c4c201e..b562a5afd89 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -38,27 +38,22 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class ConversationCreationViewModel @AssistedInject constructor( private val repository: ConversationCreationRepository, private val conversationCreator: ConversationCreator, private val passwordPolicyRepository: PasswordPolicyRepository, - @Assisted userFlow: Flow + @Assisted val currentUser: User ) : ViewModel() { private val _selectedParticipants = MutableStateFlow>(emptyList()) val selectedParticipants: StateFlow> = _selectedParticipants private val roomViewState = MutableStateFlow(RoomUIState.None) val creationState: StateFlow = roomViewState - val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { currentUser.value } + val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { currentUser } private val passwordGenerator = PasswordGenerator(passwordPolicyRepository) @@ -74,11 +69,8 @@ class ConversationCreationViewModel @AssistedInject constructor( private val _isCreatingRoom = MutableStateFlow(false) val isCreatingRoom: StateFlow = _isCreatingRoom - val currentUser: StateFlow = userFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val spreedCapabilities - get() = currentUser.value?.capabilities?.spreedCapability + get() = currentUser.capabilities?.spreedCapability val showPresetSelection: Boolean get() = CapabilitiesUtil.hasSpreedFeatureCapability( @@ -127,7 +119,7 @@ class ConversationCreationViewModel @AssistedInject constructor( private var presetsJob: Job? = null val isLoadingPresets: Boolean - get() = currentUser.value == null || (showPresetSelection && _presets.value is PresetsUiState.Loading) + get() = showPresetSelection && _presets.value is PresetsUiState.Loading val pinnedParameters: Set get() = (_presets.value as? PresetsUiState.Success) @@ -137,11 +129,8 @@ class ConversationCreationViewModel @AssistedInject constructor( .orEmpty() init { - viewModelScope.launch { - currentUser.filterNotNull().first() - if (showPresetSelection) { - loadPresets() - } + if (showPresetSelection) { + loadPresets() } } @@ -151,7 +140,7 @@ class ConversationCreationViewModel @AssistedInject constructor( _presets.value = PresetsUiState.Loading presetsJob = viewModelScope.launch { try { - val user = currentUser.filterNotNull().first() + val user = currentUser val presets = repository.getConversationPresets( ApiUtils.getCredentials(user.username, user.token), ApiUtils.getUrlForConversationPresets(user.baseUrl) @@ -220,9 +209,8 @@ class ConversationCreationViewModel @AssistedInject constructor( parametersChosenByUser.value += ConversationParameter.ROOM_TYPE to roomType recomputeParams() - val user = currentUser.value - if (allow && user != null && isPasswordEnforced && _password.value.isEmpty()) { - viewModelScope.launch { _password.value = passwordGenerator.generate(user) } + if (allow && isPasswordEnforced && _password.value.isEmpty()) { + viewModelScope.launch { _password.value = passwordGenerator.generate(currentUser) } } } @@ -254,8 +242,8 @@ class ConversationCreationViewModel @AssistedInject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun createRoomAndAddParticipants() { - val user = currentUser.value - if (_isCreatingRoom.value || user == null) { + val user = currentUser + if (_isCreatingRoom.value) { return } _isCreatingRoom.value = true @@ -302,7 +290,7 @@ class ConversationCreationViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun build(userFlow: Flow): ConversationCreationViewModel + fun build(user: User): ConversationCreationViewModel } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index f5b3b3ca4d8..729c2f51efd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -65,7 +65,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import retrofit2.Retrofit @@ -254,7 +253,7 @@ class ComposePreviewUtils private constructor(context: Context) { repository, ConversationCreator(repository, TestLogger), PasswordPolicyRepositoryImpl(ncApiCoroutines), - flowOf(currentUser) + currentUser ) } } diff --git a/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt index 2a7f5d2e669..c1be03efced 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt @@ -18,7 +18,6 @@ import com.nextcloud.talk.passwordpolicy.FakePasswordPolicyRepository import com.nextcloud.talk.utils.SpreedFeatures import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -33,22 +32,21 @@ import org.junit.Test import org.mockito.kotlin.mock /** - * What the creation screen's ViewModel does before and after the current user is known. + * What the creation screen's ViewModel does depending on the capabilities of its user. */ @OptIn(ExperimentalCoroutinesApi::class) class ConversationCreationViewModelTest { - private val users = MutableSharedFlow(replay = 1) private val repository = FakeConversationCreationRepository() private val passwordPolicyRepository = FakePasswordPolicyRepository() - private fun viewModel() = + private fun viewModel(user: User) = ConversationCreationViewModel( repository, ConversationCreator(repository, mock()), passwordPolicyRepository, - users + user ) private fun user(vararg spreedFeatures: SpreedFeatures, passwordEnforced: Boolean = false) = @@ -81,32 +79,9 @@ class ConversationCreationViewModelTest { } @Test - fun `nothing is asked of the server while the current user is unknown`() = + fun `the presets are requested when the server supports them`() = runTest { - val model = viewModel() - - assertNull(model.currentUser.value) - assertEquals(0, repository.presetCalls) - assertTrue(model.isLoadingPresets) - } - - @Test - fun `creating is refused while the current user is unknown`() = - runTest { - val model = viewModel() - - model.createRoomAndAddParticipants() - - assertNull(repository.bodyRequest) - assertNull(repository.formRequest) - } - - @Test - fun `the presets are requested once the user arrives`() = - runTest { - val model = viewModel() - - users.emit(user(SpreedFeatures.CONVERSATION_PRESETS)) + val model = viewModel(user(SpreedFeatures.CONVERSATION_PRESETS)) assertEquals(1, repository.presetCalls) assertTrue(model.presets.value is PresetsUiState.Success) @@ -117,8 +92,7 @@ class ConversationCreationViewModelTest { fun `opening a conversation to guests generates the password the server enforces`() = runTest { passwordPolicyRepository.generatedPassword = "generated" - val model = viewModel() - users.emit(user(passwordEnforced = true)) + val model = viewModel(user(passwordEnforced = true)) model.allowGuests(true) @@ -129,8 +103,7 @@ class ConversationCreationViewModelTest { fun `a password the user typed is left alone`() = runTest { passwordPolicyRepository.generatedPassword = "generated" - val model = viewModel() - users.emit(user(passwordEnforced = true)) + val model = viewModel(user(passwordEnforced = true)) model.updatePassword("hunter2") model.allowGuests(true) @@ -142,8 +115,7 @@ class ConversationCreationViewModelTest { fun `no password is generated where the server does not enforce one`() = runTest { passwordPolicyRepository.generatedPassword = "generated" - val model = viewModel() - users.emit(user()) + val model = viewModel(user()) model.allowGuests(true) @@ -154,9 +126,7 @@ class ConversationCreationViewModelTest { @Test fun `a server without the capability is never asked for presets`() = runTest { - val model = viewModel() - - users.emit(user()) + val model = viewModel(user()) assertEquals(0, repository.presetCalls) assertFalse(model.showPresetSelection) From 7a14788670fcb32936fa49941c0a5239ee388cea Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 23:03:23 +0200 Subject: [PATCH 21/76] refactor(conversationinfo): resolve the user without a pseudo coroutine The user is already loaded synchronously, so the coroutine and runCatching around it only hid that the setup runs within onCreate(). Resolve it with requireBoundUserOrFinish() and make conversationUser non-null, which removes the null checks that are no longer needed. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationInfoActivity.kt | 90 +++++++++---------- 1 file changed, 41 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index cc6f65cae61..c6c1f1510d8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -128,14 +128,14 @@ class ConversationInfoActivity : BaseActivity() { lateinit var viewModel: ConversationInfoViewModel private lateinit var conversationToken: String - private var conversationUser: User? = null + private lateinit var conversationUser: User private lateinit var credentials: String private var startGroupChat: Boolean = false private val workerData: Data? get() { - val user = conversationUser ?: return null + val user = conversationUser return if (conversationToken.isNotEmpty()) { Data.Builder() .putString(KEY_ROOM_TOKEN, conversationToken) @@ -153,7 +153,7 @@ class ConversationInfoActivity : BaseActivity() { val selectedAutocompleteUsers = intent?.getParcelableArrayListExtraProvider("selectedParticipants") ?: emptyList() - val user = conversationUser ?: return@executeIfResultOk + val user = conversationUser if (startGroupChat) { viewModel.createRoomFromOneToOne( user, @@ -171,7 +171,7 @@ class ConversationInfoActivity : BaseActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { - conversationUser?.let { viewModel.getRoom(it, conversationToken) } + viewModel.getRoom(conversationUser, conversationToken) } } @@ -179,6 +179,7 @@ class ConversationInfoActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() + val user = requireBoundUserOrFinish() ?: return conversationToken = requireNotNull( intent.getStringExtra(KEY_ROOM_TOKEN) @@ -194,20 +195,11 @@ class ConversationInfoActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] - lifecycleScope.launch { - runCatching { checkNotNull(boundUser) { "User not found" } } - .onSuccess { user -> - conversationUser = user - credentials = ApiUtils.getCredentials(user.username, user.token)!! - viewModel.getRoom(user, conversationToken) - if (upcomingEventSummary != null || upcomingEventTime != null) { - viewModel.setUpcomingEvent(upcomingEventSummary, upcomingEventTime) - } - } - .onFailure { - Log.e(TAG, "Failed to get current user") - finish() - } + conversationUser = user + credentials = ApiUtils.getCredentials(user.username, user.token)!! + viewModel.getRoom(user, conversationToken) + if (upcomingEventSummary != null || upcomingEventTime != null) { + viewModel.setUpcomingEvent(upcomingEventSummary, upcomingEventTime) } setupCompose() @@ -225,7 +217,7 @@ class ConversationInfoActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.MAIN) fun onMessageEvent(eventStatus: EventStatus) { - conversationUser?.let { viewModel.loadParticipants(it, conversationToken) } + viewModel.loadParticipants(conversationUser, conversationToken) } private fun setupCompose() { @@ -236,7 +228,7 @@ class ConversationInfoActivity : BaseActivity() { LaunchedEffect(Unit) { viewModel.uiEvent.collect { event -> - val user = conversationUser ?: return@collect + val user = conversationUser when (event) { is ConversationInfoUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(getString(event.resId)) @@ -282,7 +274,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun onGuestPasswordSave(request: PasswordRequest, password: String, copyAfterSave: Boolean) { - val user = conversationUser ?: return + val user = conversationUser if (copyAfterSave) { copyPassword(password) } @@ -321,7 +313,7 @@ class ConversationInfoActivity : BaseActivity() { onEditConversation = { editConversationResult.launch( Intent(this, ConversationInfoEditActivity::class.java).apply { - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser?.id) + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(KEY_ROOM_TOKEN, conversationToken) } ) @@ -329,17 +321,17 @@ class ConversationInfoActivity : BaseActivity() { onMessageNotificationLevelClick = { showNotificationLevelDialog() }, onCallNotificationsClick = { viewModel.toggleCallNotifications() }, onImportantConversationClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks + val user = conversationUser viewModel.toggleImportantConversation(credentials, user.baseUrl!!, conversationToken) }, onSensitiveConversationClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks + val user = conversationUser viewModel.toggleSensitiveConversation(credentials, user.baseUrl!!, conversationToken) }, - onLobbyClick = { conversationUser?.let { viewModel.toggleLobby(it, conversationToken) } }, + onLobbyClick = { viewModel.toggleLobby(conversationUser, conversationToken) }, onLobbyTimerClick = { showLobbyTimerDialog() }, onAllowGuestsClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks + val user = conversationUser val state = viewModel.uiState.value val allow = !state.guestsAllowed if (allow && isPasswordEnforced() && !state.hasPassword) { @@ -349,7 +341,7 @@ class ConversationInfoActivity : BaseActivity() { } }, onPasswordProtectionClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks + val user = conversationUser if (!viewModel.uiState.value.hasPassword) { onPasswordRequest(PasswordRequest.SET) } else { @@ -363,16 +355,16 @@ class ConversationInfoActivity : BaseActivity() { } }, onResendInvitationsClick = { - conversationUser?.let { viewModel.resendInvitations(it, conversationToken) } + viewModel.resendInvitations(conversationUser, conversationToken) }, onSharedItemsClick = { showSharedItems() }, onThreadsClick = { openThreadsOverview() }, onRecordingConsentClick = { - conversationUser?.let { viewModel.toggleRecordingConsent(it, conversationToken) } + viewModel.toggleRecordingConsent(conversationUser, conversationToken) }, onMessageExpirationClick = { showMessageExpirationDialog() }, onShareConversationClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks + val user = conversationUser val state = viewModel.uiState.value ShareUtils.shareConversationLink( this, @@ -382,7 +374,7 @@ class ConversationInfoActivity : BaseActivity() { ) }, onLockConversationClick = { - conversationUser?.let { viewModel.toggleLock(it, conversationToken) } + viewModel.toggleLock(conversationUser, conversationToken) }, onParticipantClick = { model -> handleParticipantClick(model) }, onParticipantOpsDismiss = { viewModel.setParticipantForOps(null) }, @@ -396,7 +388,7 @@ class ConversationInfoActivity : BaseActivity() { selectParticipantsToAdd() }, onListBansClick = { listBans() }, - onArchiveClick = { conversationUser?.let { viewModel.toggleArchive(it, conversationToken) } }, + onArchiveClick = { viewModel.toggleArchive(conversationUser, conversationToken) }, onLeaveConversationClick = { leaveConversation() }, onClearHistoryClick = { showClearHistoryDialog() }, onDeleteConversationClick = { showDeleteConversationDialog() } @@ -406,7 +398,7 @@ class ConversationInfoActivity : BaseActivity() { val conv = viewModel.uiState.value.conversation ?: return startActivity( Intent(this, SharedItemsActivity::class.java).apply { - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser?.id) + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(BundleKeys.KEY_CONVERSATION_NAME, conv.displayName) putExtra(KEY_ROOM_TOKEN, conversationToken) putExtra( @@ -422,7 +414,7 @@ class ConversationInfoActivity : BaseActivity() { } fun openThreadsOverview() { - val user = conversationUser ?: return + val user = conversationUser val threadsUrl = ApiUtils.getUrlForRecentThreads( version = 1, baseUrl = user.baseUrl, @@ -439,7 +431,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun showLobbyTimerDialog() { - val user = conversationUser ?: return + val user = conversationUser val currentLobbyTimer = viewModel.uiState.value.conversation?.lobbyTimer ?: 0L MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { val cal = Calendar.getInstance() @@ -514,7 +506,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun listBans() { - val user = conversationUser ?: return + val user = conversationUser val transaction = supportFragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) transaction.add(android.R.id.content, DialogBanListFragment(conversationToken, user)) @@ -543,7 +535,7 @@ class ConversationInfoActivity : BaseActivity() { } addParticipantsResult.launch( Intent(this, ContactsActivity::class.java).apply { - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser?.id) + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) putParcelableArrayListExtra("selectedParticipants", existingParticipants) putExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, true) @@ -554,7 +546,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun addParticipantsToConversation(autocompleteUsers: List) { - val user = conversationUser ?: return + val user = conversationUser val groupIds = mutableSetOf() val emailIds = mutableSetOf() val circleIds = mutableSetOf() @@ -629,7 +621,7 @@ class ConversationInfoActivity : BaseActivity() { if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { - conversationUser?.id?.let { userId -> + conversationUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( context, conversationToken, @@ -682,7 +674,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun clearHistory() { - val user = conversationUser ?: return + val user = conversationUser val caps = viewModel.uiState.value.spreedCapabilities ?: return val apiVersion = ApiUtils.getChatApiVersion(caps, intArrayOf(1)) viewModel.clearChatHistory(user, ApiUtils.getUrlForChat(apiVersion, user.baseUrl!!, conversationToken)) @@ -697,7 +689,7 @@ class ConversationInfoActivity : BaseActivity() { .build() ) - conversationUser?.id?.let { userId -> + conversationUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( context, conversationToken, @@ -713,7 +705,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun toggleModeratorStatus(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser ?: return + val user = conversationUser val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR || participant.type == ParticipantDto.ParticipantType.GUEST_MODERATOR @@ -737,7 +729,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun toggleModeratorStatusLegacy(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser ?: return + val user = conversationUser val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR) { ncApi.demoteModeratorToUser( @@ -758,7 +750,7 @@ class ConversationInfoActivity : BaseActivity() { object : Observer { override fun onSubscribe(d: Disposable) { /* unused */ } override fun onNext(genericOverall: GenericOverall) { - conversationUser?.let { viewModel.loadParticipants(it, conversationToken) } + viewModel.loadParticipants(conversationUser, conversationToken) } @SuppressLint("LongLogTag") @@ -770,7 +762,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun removeAttendeeFromConversation(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser ?: return + val user = conversationUser val observer = participantActionObserver() if (apiVersion >= ApiUtils.API_V4) { ncApi.removeAttendeeFromConversation( @@ -798,7 +790,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun banActor(actorType: String, actorId: String, internalNote: String) { - conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) } + viewModel.banActor(conversationUser, conversationToken, actorType, actorId, internalNote) } private fun handleParticipantClick(model: ParticipantModel) { @@ -811,7 +803,7 @@ class ConversationInfoActivity : BaseActivity() { val conv = state.conversation ?: return val caps = state.spreedCapabilities ?: return if (!ConversationUtils.canModerate(conv, caps)) return - val user = conversationUser ?: return + val user = conversationUser val participant = model.participant val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) when (action) { @@ -870,7 +862,7 @@ class ConversationInfoActivity : BaseActivity() { promote: Boolean, participantType: Int ) { - val user = conversationUser ?: return + val user = conversationUser val url = ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken) val call = if (promote) { ncApi.promoteAttendeeToModerator(credentials, url, participant.attendeeId, participantType) @@ -883,7 +875,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun handleBan(participant: ParticipantDto) { - val user = conversationUser ?: return + val user = conversationUser val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) val dialogBinding = DialogBanParticipantBinding.inflate(layoutInflater) val dialog = MaterialAlertDialogBuilder(this).setView(dialogBinding.root).create() From e94dde6cbcd31f2db9dc8723791308d0bcfc9ba3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 23:05:47 +0200 Subject: [PATCH 22/76] refactor(conversationinfo): inline local aliases of conversationUser conversationUser is non-null after onCreate(), so the local "val user = conversationUser" aliases are no longer needed. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationInfoActivity.kt | 109 +++++++++--------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index c6c1f1510d8..ca60c710f3b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -135,11 +135,10 @@ class ConversationInfoActivity : BaseActivity() { private val workerData: Data? get() { - val user = conversationUser return if (conversationToken.isNotEmpty()) { Data.Builder() .putString(KEY_ROOM_TOKEN, conversationToken) - .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id!!) .build() } else { null @@ -153,10 +152,9 @@ class ConversationInfoActivity : BaseActivity() { val selectedAutocompleteUsers = intent?.getParcelableArrayListExtraProvider("selectedParticipants") ?: emptyList() - val user = conversationUser if (startGroupChat) { viewModel.createRoomFromOneToOne( - user, + conversationUser, viewModel.uiState.value.participants.map { it.participant }, selectedAutocompleteUsers, conversationToken @@ -228,7 +226,6 @@ class ConversationInfoActivity : BaseActivity() { LaunchedEffect(Unit) { viewModel.uiEvent.collect { event -> - val user = conversationUser when (event) { is ConversationInfoUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(getString(event.resId)) @@ -236,11 +233,15 @@ class ConversationInfoActivity : BaseActivity() { snackbarHostState.showSnackbar(event.text) is ConversationInfoUiEvent.NavigateToChat -> { startActivity( - ChatActivity.createIntent(this@ConversationInfoActivity, user.id!!, event.token) + ChatActivity.createIntent( + this@ConversationInfoActivity, + conversationUser.id!!, + event.token + ) ) } ConversationInfoUiEvent.RefreshParticipants -> - viewModel.loadParticipants(user, conversationToken) + viewModel.loadParticipants(conversationUser, conversationToken) } } } @@ -274,20 +275,22 @@ class ConversationInfoActivity : BaseActivity() { } private fun onGuestPasswordSave(request: PasswordRequest, password: String, copyAfterSave: Boolean) { - val user = conversationUser if (copyAfterSave) { copyPassword(password) } when (request) { PasswordRequest.SET -> { - val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + val apiVersion = ApiUtils.getConversationApiVersion( + conversationUser, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) viewModel.setPassword( - user = user, - url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), + user = conversationUser, + url = ApiUtils.getUrlForRoomPassword(apiVersion, conversationUser.baseUrl!!, conversationToken), password = password ) } - PasswordRequest.ALLOW_GUESTS -> viewModel.allowGuests(user, conversationToken, true, password) + PasswordRequest.ALLOW_GUESTS -> viewModel.allowGuests(conversationUser, conversationToken, true, password) } } @@ -321,35 +324,34 @@ class ConversationInfoActivity : BaseActivity() { onMessageNotificationLevelClick = { showNotificationLevelDialog() }, onCallNotificationsClick = { viewModel.toggleCallNotifications() }, onImportantConversationClick = { - val user = conversationUser - viewModel.toggleImportantConversation(credentials, user.baseUrl!!, conversationToken) + viewModel.toggleImportantConversation(credentials, conversationUser.baseUrl!!, conversationToken) }, onSensitiveConversationClick = { - val user = conversationUser - viewModel.toggleSensitiveConversation(credentials, user.baseUrl!!, conversationToken) + viewModel.toggleSensitiveConversation(credentials, conversationUser.baseUrl!!, conversationToken) }, onLobbyClick = { viewModel.toggleLobby(conversationUser, conversationToken) }, onLobbyTimerClick = { showLobbyTimerDialog() }, onAllowGuestsClick = { - val user = conversationUser val state = viewModel.uiState.value val allow = !state.guestsAllowed if (allow && isPasswordEnforced() && !state.hasPassword) { onPasswordRequest(PasswordRequest.ALLOW_GUESTS) } else { - viewModel.allowGuests(user, conversationToken, allow) + viewModel.allowGuests(conversationUser, conversationToken, allow) } }, onPasswordProtectionClick = { - val user = conversationUser if (!viewModel.uiState.value.hasPassword) { onPasswordRequest(PasswordRequest.SET) } else { val apiVersion = - ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + ApiUtils.getConversationApiVersion( + conversationUser, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) viewModel.setPassword( - user = user, - url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), + user = conversationUser, + url = ApiUtils.getUrlForRoomPassword(apiVersion, conversationUser.baseUrl!!, conversationToken), password = "" ) } @@ -364,13 +366,12 @@ class ConversationInfoActivity : BaseActivity() { }, onMessageExpirationClick = { showMessageExpirationDialog() }, onShareConversationClick = { - val user = conversationUser val state = viewModel.uiState.value ShareUtils.shareConversationLink( this, - user.baseUrl, + conversationUser.baseUrl, state.conversationToken, - CapabilitiesUtil.canGeneratePrettyURL(user) + CapabilitiesUtil.canGeneratePrettyURL(conversationUser) ) }, onLockConversationClick = { @@ -414,15 +415,14 @@ class ConversationInfoActivity : BaseActivity() { } fun openThreadsOverview() { - val user = conversationUser val threadsUrl = ApiUtils.getUrlForRecentThreads( version = 1, - baseUrl = user.baseUrl, + baseUrl = conversationUser.baseUrl, token = conversationToken ) startActivity( Intent(context, ThreadsOverviewActivity::class.java).apply { - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(KEY_ROOM_TOKEN, conversationToken) putExtra(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads)) putExtra(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) @@ -431,7 +431,6 @@ class ConversationInfoActivity : BaseActivity() { } private fun showLobbyTimerDialog() { - val user = conversationUser val currentLobbyTimer = viewModel.uiState.value.conversation?.lobbyTimer ?: 0L MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { val cal = Calendar.getInstance() @@ -439,7 +438,7 @@ class ConversationInfoActivity : BaseActivity() { datePicker { _, date -> showTimePicker(date) { selectedDate -> viewModel.setLobbyTimerAndSubmit( - user, + conversationUser, conversationToken, selectedDate.timeInMillis / DateConstants.SECOND_DIVIDER ) @@ -506,10 +505,9 @@ class ConversationInfoActivity : BaseActivity() { } private fun listBans() { - val user = conversationUser val transaction = supportFragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - transaction.add(android.R.id.content, DialogBanListFragment(conversationToken, user)) + transaction.add(android.R.id.content, DialogBanListFragment(conversationToken, conversationUser)) .addToBackStack(null) .commit() } @@ -546,7 +544,6 @@ class ConversationInfoActivity : BaseActivity() { } private fun addParticipantsToConversation(autocompleteUsers: List) { - val user = conversationUser val groupIds = mutableSetOf() val emailIds = mutableSetOf() val circleIds = mutableSetOf() @@ -562,7 +559,7 @@ class ConversationInfoActivity : BaseActivity() { } val data = Data.Builder() - .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id!!) .putString(BundleKeys.KEY_TOKEN, conversationToken) .putStringArray(BundleKeys.KEY_SELECTED_USERS, userIds.toTypedArray()) .putStringArray(BundleKeys.KEY_SELECTED_GROUPS, groupIds.toTypedArray()) @@ -581,7 +578,7 @@ class ConversationInfoActivity : BaseActivity() { .observe(this) { workInfo: WorkInfo? -> when (workInfo?.state) { WorkInfo.State.SUCCEEDED -> { - viewModel.loadParticipants(user, conversationToken) + viewModel.loadParticipants(conversationUser, conversationToken) reportParticipantsNotAdded(workInfo.outputData, names) } WorkInfo.State.FAILED -> reportParticipantsNotAdded(workInfo.outputData, names) @@ -674,10 +671,12 @@ class ConversationInfoActivity : BaseActivity() { } private fun clearHistory() { - val user = conversationUser val caps = viewModel.uiState.value.spreedCapabilities ?: return val apiVersion = ApiUtils.getChatApiVersion(caps, intArrayOf(1)) - viewModel.clearChatHistory(user, ApiUtils.getUrlForChat(apiVersion, user.baseUrl!!, conversationToken)) + viewModel.clearChatHistory( + conversationUser, + ApiUtils.getUrlForChat(apiVersion, conversationUser.baseUrl!!, conversationToken) + ) } private fun deleteConversation() { @@ -705,14 +704,13 @@ class ConversationInfoActivity : BaseActivity() { } private fun toggleModeratorStatus(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR || participant.type == ParticipantDto.ParticipantType.GUEST_MODERATOR ) { ncApi.demoteAttendeeFromModerator( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.attendeeId, null )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) @@ -721,7 +719,7 @@ class ConversationInfoActivity : BaseActivity() { ) { ncApi.promoteAttendeeToModerator( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.attendeeId, null )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) @@ -729,18 +727,17 @@ class ConversationInfoActivity : BaseActivity() { } private fun toggleModeratorStatusLegacy(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR) { ncApi.demoteModeratorToUser( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.userId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } else if (participant.type == ParticipantDto.ParticipantType.USER) { ncApi.promoteUserToModerator( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.userId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } @@ -762,12 +759,11 @@ class ConversationInfoActivity : BaseActivity() { } private fun removeAttendeeFromConversation(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser val observer = participantActionObserver() if (apiVersion >= ApiUtils.API_V4) { ncApi.removeAttendeeFromConversation( credentials, - ApiUtils.getUrlForAttendees(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForAttendees(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.attendeeId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } else { @@ -776,13 +772,21 @@ class ConversationInfoActivity : BaseActivity() { ) { ncApi.removeParticipantFromConversation( credentials, - ApiUtils.getUrlForRemovingParticipantFromConversation(user.baseUrl!!, conversationToken, true), + ApiUtils.getUrlForRemovingParticipantFromConversation( + conversationUser.baseUrl!!, + conversationToken, + true + ), participant.sessionId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } else { ncApi.removeParticipantFromConversation( credentials, - ApiUtils.getUrlForRemovingParticipantFromConversation(user.baseUrl!!, conversationToken, false), + ApiUtils.getUrlForRemovingParticipantFromConversation( + conversationUser.baseUrl!!, + conversationToken, + false + ), participant.userId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } @@ -803,9 +807,8 @@ class ConversationInfoActivity : BaseActivity() { val conv = state.conversation ?: return val caps = state.spreedCapabilities ?: return if (!ConversationUtils.canModerate(conv, caps)) return - val user = conversationUser val participant = model.participant - val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) when (action) { ParticipantOpsAction.PromoteToModerator, ParticipantOpsAction.DemoteFromModerator -> @@ -862,8 +865,7 @@ class ConversationInfoActivity : BaseActivity() { promote: Boolean, participantType: Int ) { - val user = conversationUser - val url = ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken) + val url = ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken) val call = if (promote) { ncApi.promoteAttendeeToModerator(credentials, url, participant.attendeeId, participantType) } else { @@ -875,12 +877,11 @@ class ConversationInfoActivity : BaseActivity() { } private fun handleBan(participant: ParticipantDto) { - val user = conversationUser - val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) val dialogBinding = DialogBanParticipantBinding.inflate(layoutInflater) val dialog = MaterialAlertDialogBuilder(this).setView(dialogBinding.root).create() dialogBinding.avatarImage.loadUserAvatar( - user, + conversationUser, participant.actorId!!, requestBigSize = true, ignoreCache = false From c3c21805d0603ae812d792df7516f3b7679933b3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 23:09:06 +0200 Subject: [PATCH 23/76] refactor(user): assign the bound user directly to the activity property CallActivity, ConversationInfoActivity and SharedItemsActivity kept the bound user in a local only to assign it to their non-null property. Assign it directly. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/CallActivity.kt | 3 +-- .../talk/conversationinfo/ConversationInfoActivity.kt | 7 +++---- .../talk/shareditems/activities/SharedItemsActivity.kt | 7 +++---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index fd0a68b2a62..913472e63d1 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -532,8 +532,7 @@ class CallActivity : CallBaseActivity() { return } processExtras(intent.extras!!) - val user = requireBoundUserOrFinish() ?: return - conversationUser = user + conversationUser = requireBoundUserOrFinish() ?: return if (warnAndFinishIfCallEndToEndEncryptionUnsupported()) { return diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index ca60c710f3b..d31ae98247a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -177,7 +177,7 @@ class ConversationInfoActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) applyUserTheme() - val user = requireBoundUserOrFinish() ?: return + conversationUser = requireBoundUserOrFinish() ?: return conversationToken = requireNotNull( intent.getStringExtra(KEY_ROOM_TOKEN) @@ -193,9 +193,8 @@ class ConversationInfoActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] - conversationUser = user - credentials = ApiUtils.getCredentials(user.username, user.token)!! - viewModel.getRoom(user, conversationToken) + credentials = ApiUtils.getCredentials(conversationUser.username, conversationUser.token)!! + viewModel.getRoom(conversationUser, conversationToken) if (upcomingEventSummary != null || upcomingEventTime != null) { viewModel.setUpcomingEvent(upcomingEventSummary, upcomingEventTime) } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index bbbdc034ab9..16bf0ce0291 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -85,8 +85,7 @@ class SharedItemsActivity : BaseActivity() { applyUserTheme() val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) - val user = requireBoundUserOrFinish() ?: return - conversationUser = user + conversationUser = requireBoundUserOrFinish() ?: return val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) val isOne2One = intent.getBooleanExtra(KEY_IS_ONE_2_ONE, false) @@ -106,7 +105,7 @@ class SharedItemsActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[SharedItemsViewModel::class.java] viewModel.viewState.observe(this) { state -> - handleModelChange(state, user, roomToken, isUserConversationOwnerOrModerator, isOne2One) + handleModelChange(state, conversationUser, roomToken, isUserConversationOwnerOrModerator, isOne2One) } binding.imageRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -118,7 +117,7 @@ class SharedItemsActivity : BaseActivity() { } }) - viewModel.initialize(user, roomToken) + viewModel.initialize(conversationUser, roomToken) } private fun handleModelChange( From a9de2d7ecafb34d01b8fcc27bd81a4ca8eb3ce64 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 23:27:26 +0200 Subject: [PATCH 24/76] refactor(user): set up the bound user first in onCreate() of all activities Activities checked their account at different points of onCreate(), some only after reading extras, creating view models or showing their UI. setUpBoundUserOrFinish() resolves the account and applies its theme in one call, and all bound activities now call it right after injection, so a missing account ends the screen before anything else is set up. ScheduledMessagesActivity and CallNotificationActivity use it instead of loading the user themselves, Profile and Settings close instead of crashing when their account is missing, and the conversation list closes when there is no account at all. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 6 ++++++ .../CallNotificationActivity.kt | 17 ++++++----------- .../com/nextcloud/talk/chat/ChatActivity.kt | 4 +--- .../talk/chat/ScheduledMessagesActivity.kt | 6 ++---- .../viewmodels/ScheduledMessagesViewModel.kt | 8 ++------ .../nextcloud/talk/contacts/ContactsActivity.kt | 3 +-- .../ConversationCreationActivity.kt | 3 +-- .../ConversationInfoActivity.kt | 3 +-- .../ConversationInfoEditActivity.kt | 3 +-- .../ConversationsListActivity.kt | 11 ++++++++--- .../talk/diagnosis/DiagnosisActivity.kt | 3 +-- .../talk/invitation/InvitationsActivity.kt | 3 +-- .../talk/location/LocationPickerActivity.kt | 4 +--- .../activities/MediaViewerActivity.kt | 5 ++--- .../talk/messagesearch/MessageSearchActivity.kt | 3 +-- .../ListOpenConversationsActivity.kt | 4 +--- .../nextcloud/talk/profile/ProfileActivity.kt | 2 +- .../nextcloud/talk/settings/SettingsActivity.kt | 2 +- .../activities/SharedItemsActivity.kt | 4 +--- .../threadsoverview/ThreadsOverviewActivity.kt | 3 +-- .../talk/translate/ui/TranslateActivity.kt | 3 +-- 21 files changed, 41 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index adfe811d1f7..dd909da2b97 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -156,6 +156,12 @@ open class BaseActivity : AppCompatActivity() { boundUser?.let { viewThemeUtils = viewThemeUtilsFactory.forUser(it) } } + /** + * Resolves [boundUser] and applies its theme, or finishes the activity and returns null if the account doesn't + * exist. Must be called right after injection, before anything else is set up. + */ + protected fun setUpBoundUserOrFinish(): User? = requireBoundUserOrFinish()?.also { applyUserTheme() } + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 8c9ad17ec1d..52f81718151 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -37,7 +37,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import kotlinx.coroutines.runBlocking import okhttp3.Cache import java.io.IOException import javax.inject.Inject @@ -59,9 +58,7 @@ class CallNotificationActivity : CallBaseActivity() { private var callFlag: Int = 0 private var isOneToOneCall: Boolean = true private var conversationName: String? = null - private var internalUserId: Long = -1 - - private var userBeingCalled: User? = null + private lateinit var userBeingCalled: User private var leavingScreen = false private var handler: Handler? = null private var binding: CallNotificationActivityBinding? = null @@ -69,13 +66,12 @@ class CallNotificationActivity : CallBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + userBeingCalled = setUpBoundUserOrFinish() ?: return binding = CallNotificationActivityBinding.inflate(layoutInflater) setContentView(binding!!.root) hideNavigationIfNoPipAvailable() handleExtras() - userBeingCalled = runBlocking { userManager.getUserWithId(internalUserId) } setupCallTypeDescription() setupCallAnswerButtons() @@ -93,13 +89,12 @@ class CallNotificationActivity : CallBaseActivity() { callFlag = extras.getInt(BundleKeys.KEY_CALL_FLAG) isOneToOneCall = extras.getBoolean(KEY_ROOM_ONE_TO_ONE) conversationName = extras.getString(BundleKeys.KEY_CONVERSATION_NAME, "") - internalUserId = extras.getLong(BundleKeys.KEY_INTERNAL_USER_ID) } private fun setupAvatar(isOneToOneCall: Boolean, conversationName: String?) { if (isOneToOneCall) { binding!!.avatarImageView.loadUserAvatar( - userBeingCalled!!, + userBeingCalled, conversationName!!, true, false @@ -111,7 +106,7 @@ class CallNotificationActivity : CallBaseActivity() { private fun hasCallFlagsCapability(): Boolean { val apiVersion = ApiUtils.getConversationApiVersion( - userBeingCalled!!, + userBeingCalled, intArrayOf( ApiUtils.API_V4, ApiUtils.API_V3, @@ -121,7 +116,7 @@ class CallNotificationActivity : CallBaseActivity() { return apiVersion >= ApiUtils.API_V3 && hasSpreedFeatureCapability( - userBeingCalled?.capabilities?.spreedCapability, + userBeingCalled.capabilities?.spreedCapability, SpreedFeatures.CONVERSATION_CALL_FLAGS ) } @@ -203,7 +198,7 @@ class CallNotificationActivity : CallBaseActivity() { } private fun proceedToCall() { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled?.capabilities?.spreedCapability)) { + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled.capabilities?.spreedCapability)) { Toast.makeText(context, R.string.nc_call_e2ee_not_supported, Toast.LENGTH_LONG).show() hangup() return diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 3dce6e09cf6..202d7aa61ec 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -602,11 +602,9 @@ class ChatActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), // and registerForActivityResult() must be called before the activity is started. - initialUser = requireBoundUserOrFinish() ?: return + initialUser = setUpBoundUserOrFinish() ?: return binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index c1a1d9069ae..ee425cb9310 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -162,15 +162,13 @@ class ScheduledMessagesActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + val user = setUpBoundUserOrFinish() ?: return scheduledMessagesViewModel = ViewModelProvider(this, viewModelFactory)[ScheduledMessagesViewModel::class.java] + scheduledMessagesViewModel.setUser(user) setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val currentUser by scheduledMessagesViewModel.currentUserState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - scheduledMessagesViewModel.loadUser(resolveUserIdFromIntent()) - } MaterialTheme(colorScheme = colorScheme) { CompositionLocalProvider( LocalViewThemeUtils provides viewThemeUtils, diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt index 144559cc508..91b7091c6da 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt @@ -15,7 +15,6 @@ import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.opengraph.ReferenceDto -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.message.SendMessageUtils import kotlinx.coroutines.Dispatchers @@ -30,7 +29,6 @@ import javax.inject.Inject class ScheduledMessagesViewModel @Inject constructor( private val chatRepository: ChatMessageRepository, - private val userManager: UserManager, private val chatNetworkDataSource: ChatNetworkDataSource ) : ViewModel() { @@ -143,10 +141,8 @@ class ScheduledMessagesViewModel @Inject constructor( } } - fun loadUser(userId: Long) { - viewModelScope.launch { - _currentUserState.value = userManager.getUserWithId(userId) - } + fun setUser(user: User) { + _currentUserState.value = user } @Suppress("LongParameterList") diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index 9439497c0e7..650b41ec700 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -38,8 +38,7 @@ class ContactsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - val user = requireBoundUserOrFinish() ?: return + val user = setUpBoundUserOrFinish() ?: return contactsViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ContactsViewModel::class.java) { viewModelFactory.build(user) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 9d63e774b7e..14e217a1792 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -124,8 +124,7 @@ class ConversationCreationActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - val user = requireBoundUserOrFinish() ?: return + val user = setUpBoundUserOrFinish() ?: return val conversationCreationViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ConversationCreationViewModel::class.java) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index d31ae98247a..befdeae9421 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -176,8 +176,7 @@ class ConversationInfoActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - conversationUser = requireBoundUserOrFinish() ?: return + conversationUser = setUpBoundUserOrFinish() ?: return conversationToken = requireNotNull( intent.getStringExtra(KEY_ROOM_TOKEN) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index 928f7f3b6e1..f0d1780576e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -75,10 +75,9 @@ class ConversationInfoEditActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + val user = setUpBoundUserOrFinish() ?: return val roomToken = intent.extras?.getString(BundleKeys.KEY_ROOM_TOKEN)!! - val user = requireBoundUserOrFinish() ?: return conversationInfoEditViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 4654aac8703..5990d0cc455 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -202,16 +202,21 @@ class ConversationsListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + // As entry point, the list falls back to the default account if the requested one doesn't exist (anymore). + val user = boundUser ?: defaultAccountProvider.getDefaultUserBlocking() ?: run { + Log.e(TAG, "No account available") + finish() + return + } + currentUser = user applyUserTheme() ecosystemManager = EcosystemManager(this@ConversationsListActivity) - currentUser = boundUser ?: defaultAccountProvider.getDefaultUserBlocking() - currentUser?.takeIf { !it.current }?.let { user -> + if (!user.current) { // The shown account becomes the last used one, which the account switcher and status views rely on. lifecycleScope.launch { userManager.setUserAsActive(user) } } - val user = currentUser!! conversationsListViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ConversationsListViewModel::class.java) { diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index e278f207b3d..024eccb99fa 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -90,8 +90,7 @@ class DiagnosisActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - val user = requireBoundUserOrFinish() ?: return + val user = setUpBoundUserOrFinish() ?: return diagnosisViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(DiagnosisViewModel::class.java) { viewModelFactory.build(user) } diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index 5e2f7383ed0..e7766da05c6 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -57,11 +57,10 @@ class InvitationsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + currentUser = setUpBoundUserOrFinish() ?: return invitationsViewModel = ViewModelProvider(this, viewModelFactory)[InvitationsViewModel::class.java] - currentUser = requireBoundUserOrFinish() ?: return invitationsViewModel.fetchInvitations(currentUser) binding = ActivityInvitationsBinding.inflate(layoutInflater) diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index 5a2d14a44ea..dfb20b518a5 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -68,13 +68,11 @@ class LocationPickerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + val user = setUpBoundUserOrFinish() ?: return roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) - val user = requireBoundUserOrFinish() ?: return - viewModel = ViewModelProvider(this, viewModelFactory)[LocationPickerViewModel::class.java] val geocodingResult: GeocodingResult? = if (savedInstanceState != null) { diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 9ce24c2775f..6ab3d3baa78 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -55,14 +55,13 @@ class MediaViewerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + val user = setUpBoundUserOrFinish() ?: return val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) val startMessageId = intent.getLongExtra(EXTRA_START_MESSAGE_ID, -1L) - val user = boundUser - if (roomToken == null || seedItems.isNullOrEmpty() || user == null) { + if (roomToken == null || seedItems.isNullOrEmpty()) { Log.e(TAG, "Missing data to open the media viewer") finish() return diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 9dfd3697b44..fb343776751 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -66,7 +66,7 @@ class MessageSearchActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + user = setUpBoundUserOrFinish() ?: return binding = ActivityMessageSearchBinding.inflate(layoutInflater) setupActionBar() @@ -74,7 +74,6 @@ class MessageSearchActivity : BaseActivity() { initSystemBars() viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] - user = requireBoundUserOrFinish() ?: return val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! viewModel.initialize(user, roomToken) setupStateObserver() diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index 0424d0d241b..d82fe69f5d9 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -36,9 +36,7 @@ class ListOpenConversationsActivity : BaseActivity() { adjustUIForAPILevel35() super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - - val user = requireBoundUserOrFinish() ?: return + val user = setUpBoundUserOrFinish() ?: return openConversationsViewModel = ViewModelProvider( this, diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index d2dadbe794b..25c7bf687fa 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -111,7 +111,7 @@ class ProfileActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + setUpBoundUserOrFinish() ?: return val restoredEdit = savedInstanceState?.getBoolean(KEY_EDIT_MODE) ?: false profileUiState = profileUiState.copy(isEditMode = restoredEdit) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index d76405c120c..22e7809fb29 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -172,7 +172,7 @@ class SettingsActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + setUpBoundUserOrFinish() ?: return networkMonitor.isOnlineLiveData.observe(this) { online -> isOnline.value = online handleNetworkChange(isOnline.value) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 16bf0ce0291..30bf635c74b 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -82,11 +82,9 @@ class SharedItemsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + conversationUser = setUpBoundUserOrFinish() ?: return val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) - conversationUser = requireBoundUserOrFinish() ?: return - val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) val isOne2One = intent.getBooleanExtra(KEY_IS_ONE_2_ONE, false) diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index ff36e7b48c7..bdcfd479e21 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -69,8 +69,7 @@ class ThreadsOverviewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - val user = requireBoundUserOrFinish() ?: return + val user = setUpBoundUserOrFinish() ?: return threadsOverviewViewModel = ViewModelProvider( this, ViewModelFactoryWithParams(ThreadsOverviewViewModel::class.java) { diff --git a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt index b88fa042aec..63a543e5b23 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt @@ -50,10 +50,9 @@ class TranslateActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - applyUserTheme() + val user = setUpBoundUserOrFinish() ?: return binding = ActivityTranslateBinding.inflate(layoutInflater) - val user = requireBoundUserOrFinish() ?: return viewModel = ViewModelProvider( this, ViewModelFactoryWithParams(TranslateViewModel::class.java) { viewModelFactory.build(user) } From d5c14a1c7aad8c7a5a9af0ee517c2801c1231cf8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 28 Sep 2026 23:36:34 +0200 Subject: [PATCH 25/76] refactor(user): let entry points fall back to the default account in BaseActivity The conversation list handled a requested account that no longer exists by itself and was the only screen not using setUpBoundUserOrFinish(). boundUser now falls back to the default account for activities with allowsDefaultAccount, so the list sets up its user like all other screens and is themed for the account it actually shows. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/BaseActivity.kt | 6 ++++-- .../talk/conversationlist/ConversationsListActivity.kt | 8 +------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index dd909da2b97..54cfe520cc2 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -131,11 +131,13 @@ open class BaseActivity : AppCompatActivity() { } /** - * The account this activity was started for (see [resolveUserIdFromIntent]), or null if it doesn't exist. - * Loaded once on first access, which must be after injection. + * The account this activity was started for (see [resolveUserIdFromIntent]), or null if it doesn't exist. For + * activities with [allowsDefaultAccount], a requested account that doesn't exist (anymore) falls back to the + * default account. Loaded once on first access, which must be after injection. */ protected val boundUser: User? by lazy { runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + ?: if (allowsDefaultAccount) defaultAccountProvider.getDefaultUserBlocking() else null } /** diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 5990d0cc455..4fe83736efe 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -202,14 +202,8 @@ class ConversationsListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - // As entry point, the list falls back to the default account if the requested one doesn't exist (anymore). - val user = boundUser ?: defaultAccountProvider.getDefaultUserBlocking() ?: run { - Log.e(TAG, "No account available") - finish() - return - } + val user = setUpBoundUserOrFinish() ?: return currentUser = user - applyUserTheme() ecosystemManager = EcosystemManager(this@ConversationsListActivity) if (!user.current) { From e88c94ddbe03399d06b4470dbcffbf2bcc2f5380 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 06:55:41 +0200 Subject: [PATCH 26/76] fix(call): don't crash when CallActivity closes during onCreate() onDestroy() always hung up the call and updated the phone state, so every early exit from onCreate() (missing account, missing extras, unsupported end-to-end encryption) crashed on state that was never set up. Only clean up the call once onCreate() got past its early exits, and register the end-call receiver there as well. CallActivity can now set up its account first like all other activities, so BaseActivity only exposes setUpBoundUserOrFinish(). Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 6 +- .../nextcloud/talk/activities/CallActivity.kt | 88 ++++++++++--------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 54cfe520cc2..01a57500fd9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -135,7 +135,7 @@ open class BaseActivity : AppCompatActivity() { * activities with [allowsDefaultAccount], a requested account that doesn't exist (anymore) falls back to the * default account. Loaded once on first access, which must be after injection. */ - protected val boundUser: User? by lazy { + private val boundUser: User? by lazy { runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: if (allowsDefaultAccount) defaultAccountProvider.getDefaultUserBlocking() else null } @@ -143,7 +143,7 @@ open class BaseActivity : AppCompatActivity() { /** * Returns [boundUser], or finishes the activity and returns null if the account doesn't exist. */ - protected fun requireBoundUserOrFinish(): User? = + private fun requireBoundUserOrFinish(): User? = boundUser ?: run { Log.e(TAG, "No user found for id ${resolveUserIdFromIntent()}") finish() @@ -154,7 +154,7 @@ open class BaseActivity : AppCompatActivity() { * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. * Must be called after injection and before any view is themed. */ - protected fun applyUserTheme() { + private fun applyUserTheme() { boundUser?.let { viewThemeUtils = viewThemeUtilsFactory.forUser(it) } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 913472e63d1..252d4d91b10 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -323,6 +323,9 @@ class CallActivity : CallBaseActivity() { private var hasExternalSignalingServer = false private var conversationPassword: String? = null private var powerManagerUtils: PowerManagerUtils? = null + + // Whether onCreate() got past its early exits, so onDestroy() has a call to clean up. + private var isCallSetUp = false private var handler: Handler? = null private val callingTimeoutRunnable = Runnable { setCallState(CallStatus.CALLING_TIMEOUT) } @@ -442,19 +445,7 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "onCreate") super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) - applyUserTheme() - - // Register broadcast receiver for ending call from notification - val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) - - // internal receiver for notification actions, so not exported - registerPermissionHandlerBroadcastReceiver( - endCallFromNotificationReceiver, - endCallFilter, - permissionUtil!!.privateBroadcastPermission, - null, - ReceiverFlag.NotExported - ) + conversationUser = setUpBoundUserOrFinish() ?: return callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java] @@ -532,7 +523,6 @@ class CallActivity : CallBaseActivity() { return } processExtras(intent.extras!!) - conversationUser = requireBoundUserOrFinish() ?: return if (warnAndFinishIfCallEndToEndEncryptionUnsupported()) { return @@ -544,6 +534,20 @@ class CallActivity : CallBaseActivity() { } powerManagerUtils = PowerManagerUtils() + // Register broadcast receiver for ending call from notification + val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) + + // internal receiver for notification actions, so not exported + registerPermissionHandlerBroadcastReceiver( + endCallFromNotificationReceiver, + endCallFilter, + permissionUtil!!.privateBroadcastPermission, + null, + ReceiverFlag.NotExported + ) + + isCallSetUp = true + setCallState(CallStatus.CONNECTING) initRaiseHandViewModel() @@ -1554,36 +1558,38 @@ class CallActivity : CallBaseActivity() { public override fun onDestroy() { Log.d(TAG, "onDestroy: currentCallStatus=$currentCallStatus") - // The call cannot survive the activity being destroyed (WebRTC connections, local stream and - // signaling listeners all live here), so always clean up and hang up. Background survival is - // achieved via moveTaskToBack/PiP, which do not destroy the activity. - if (signalingMessageReceiver != null) { - signalingMessageReceiver!!.removeListener(localParticipantMessageListener) - signalingMessageReceiver!!.removeListener(offerMessageListener) - } - if (localStream != null) { - localStream!!.dispose() - localStream = null - Log.d(TAG, "Disposed localStream") - } else { - Log.d(TAG, "localStream is null") - } - if (currentCallStatus !== CallStatus.LEAVING) { - hangup(true, false) - } - CallForegroundService.stop(applicationContext) + if (isCallSetUp) { + // The call cannot survive the activity being destroyed (WebRTC connections, local stream and + // signaling listeners all live here), so always clean up and hang up. Background survival is + // achieved via moveTaskToBack/PiP, which do not destroy the activity. + if (signalingMessageReceiver != null) { + signalingMessageReceiver!!.removeListener(localParticipantMessageListener) + signalingMessageReceiver!!.removeListener(offerMessageListener) + } + if (localStream != null) { + localStream!!.dispose() + localStream = null + Log.d(TAG, "Disposed localStream") + } else { + Log.d(TAG, "localStream is null") + } + if (currentCallStatus !== CallStatus.LEAVING) { + hangup(true, false) + } + CallForegroundService.stop(applicationContext) - powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) - try { - unregisterReceiver(endCallFromNotificationReceiver) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) - } + try { + unregisterReceiver(endCallFromNotificationReceiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) + } - pendingCallIntent?.let { - Log.d(TAG, "onDestroy: starting CallActivity for pending call intent") - startActivity(it) + pendingCallIntent?.let { + Log.d(TAG, "onDestroy: starting CallActivity for pending call intent") + startActivity(it) + } } super.onDestroy() From 1b7e104768c769e9dc15075bb2edd7ee855ddb46 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 07:04:42 +0200 Subject: [PATCH 27/76] refactor(chat): remove always-true user checks in scheduled messages The user is set synchronously in onCreate() since it comes from setUpBoundUserOrFinish(), so ScheduledMessagesViewModel doesn't need to expose it as a nullable flow and the activity doesn't need to check it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/ScheduledMessagesActivity.kt | 78 +++++++++---------- .../viewmodels/ScheduledMessagesViewModel.kt | 8 +- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index ee425cb9310..4589afc536a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -132,6 +132,8 @@ class ScheduledMessagesActivity : BaseActivity() { private lateinit var scheduledMessagesViewModel: ScheduledMessagesViewModel + private lateinit var conversationUser: User + @Inject lateinit var networkMonitor: NetworkMonitor @@ -162,13 +164,12 @@ class ScheduledMessagesActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return + conversationUser = setUpBoundUserOrFinish() ?: return scheduledMessagesViewModel = ViewModelProvider(this, viewModelFactory)[ScheduledMessagesViewModel::class.java] - scheduledMessagesViewModel.setUser(user) + scheduledMessagesViewModel.setUser(conversationUser) setContent { val colorScheme = viewThemeUtils.getColorScheme(this) - val currentUser by scheduledMessagesViewModel.currentUserState.collectAsStateWithLifecycle() MaterialTheme(colorScheme = colorScheme) { CompositionLocalProvider( LocalViewThemeUtils provides viewThemeUtils, @@ -176,46 +177,43 @@ class ScheduledMessagesActivity : BaseActivity() { LocalShowThreadButton provides false ) { ColoredStatusBar() - currentUser?.let { user -> - ScheduledMessagesScreen( - user = user, - conversationName = conversationName, - scheduledMessagesViewModel = scheduledMessagesViewModel, - dateUtils = dateUtils, - viewThemeUtils = viewThemeUtils, - onBack = { finish() }, - onLoadScheduledMessages = { loadScheduledMessages(user) }, - onSendNow = { message -> - sendNow(message, user) - }, - onReschedule = { message, sendAt, sendWithoutNotification -> - reschedule(message, sendAt, sendWithoutNotification, user) - }, - onEdit = { message, sendAt -> - edit(message, sendAt, user) - }, - onDeleteScheduledMessage = { message -> deleteScheduledMessage(message, user) }, - onOpenParentMessage = { messageId -> - openParentMessage(messageId) - }, - onOpenThread = { threadId -> - openThread(threadId) - }, - threadTitle = threadTitle, - isThreadView = isThreadView, - onCopyScheduledMessage = { message -> - copyScheduledMessage(message) - } - ) - } + ScheduledMessagesScreen( + user = conversationUser, + conversationName = conversationName, + scheduledMessagesViewModel = scheduledMessagesViewModel, + dateUtils = dateUtils, + viewThemeUtils = viewThemeUtils, + onBack = { finish() }, + onLoadScheduledMessages = { loadScheduledMessages(conversationUser) }, + onSendNow = { message -> + sendNow(message, conversationUser) + }, + onReschedule = { message, sendAt, sendWithoutNotification -> + reschedule(message, sendAt, sendWithoutNotification, conversationUser) + }, + onEdit = { message, sendAt -> + edit(message, sendAt, conversationUser) + }, + onDeleteScheduledMessage = { message -> deleteScheduledMessage(message, conversationUser) }, + onOpenParentMessage = { messageId -> + openParentMessage(messageId) + }, + onOpenThread = { threadId -> + openThread(threadId) + }, + threadTitle = threadTitle, + isThreadView = isThreadView, + onCopyScheduledMessage = { message -> + copyScheduledMessage(message) + } + ) } // CompositionLocalProvider } } } private fun openThread(threadId: Long) { - val user = scheduledMessagesViewModel.currentUserState.value ?: return - val intent = ChatActivity.createIntent(this, user.id!!, roomToken).apply { + val intent = ChatActivity.createIntent(this, conversationUser.id!!, roomToken).apply { putExtra(KEY_THREAD_ID, threadId) } startActivity(intent) @@ -978,16 +976,14 @@ class ScheduledMessagesActivity : BaseActivity() { } private fun openParentMessage(messageId: Long?) { - val user = scheduledMessagesViewModel.currentUserState.value ?: return - val intent = ChatActivity.createIntent(this, user.id!!, roomToken).apply { + val intent = ChatActivity.createIntent(this, conversationUser.id!!, roomToken).apply { messageId?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it.toString()) } } startActivity(intent) } private fun openThreadParentMessage(messageId: Long?, threadId: Long?) { - val user = scheduledMessagesViewModel.currentUserState.value ?: return - val intent = ChatActivity.createIntent(this, user.id!!, roomToken).apply { + val intent = ChatActivity.createIntent(this, conversationUser.id!!, roomToken).apply { threadId?.let { putExtra(BundleKeys.KEY_THREAD_ID, it) } messageId?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it.toString()) } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt index 91b7091c6da..24186a39689 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt @@ -51,8 +51,7 @@ class ScheduledMessagesViewModel @Inject constructor( data class ScheduledMessageErrorState(val error: Throwable? = null) : ScheduledMessageActionState - private val _currentUserState = MutableStateFlow(null) - val currentUserState: StateFlow = _currentUserState + private lateinit var user: User sealed interface SendNowMessageState object SendNowMessageIdleState : SendNowMessageState @@ -115,7 +114,6 @@ class ScheduledMessagesViewModel @Inject constructor( viewModelScope.launch { try { - val user = _currentUserState.value ?: return@launch if (user.baseUrl.isNullOrBlank()) { return@launch } @@ -142,7 +140,7 @@ class ScheduledMessagesViewModel @Inject constructor( } fun setUser(user: User) { - _currentUserState.value = user + this.user = user } @Suppress("LongParameterList") @@ -214,8 +212,6 @@ class ScheduledMessagesViewModel @Inject constructor( private suspend fun getParentMessageById(token: String, parentMessageId: Long, threadId: Long?): ChatMessage? = withContext(Dispatchers.IO) { - val user = _currentUserState.value ?: return@withContext null - val credentials = user.getCredentials() val apiVersion = ApiUtils.getChatApiVersion( From 0574dfb1702e8cd6fea5eed3efdb6f73d84da011 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 07:42:28 +0200 Subject: [PATCH 28/76] refactor: create assisted view models with a common delegate Activities created their assisted view models in different ways, mostly with a lateinit property and ViewModelProvider in onCreate(). assistedViewModels { } wraps viewModels with ViewModelFactoryWithParams, so all of them are now declared as a lazy val next to their factory. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 16 +++++----- .../talk/chat/ScheduledMessagesActivity.kt | 10 +++---- .../viewmodels/ScheduledMessagesViewModel.kt | 20 +++++++------ .../talk/contacts/ContactsActivity.kt | 14 ++++----- .../ConversationCreationActivity.kt | 15 ++++------ .../ConversationsListActivity.kt | 30 +++++-------------- .../talk/dagger/modules/ViewModelModule.kt | 15 ++++++---- .../talk/diagnosis/DiagnosisActivity.kt | 14 ++++----- .../ListOpenConversationsActivity.kt | 16 +++++----- .../activities/RemoteFileBrowserActivity.kt | 10 ++----- .../activities/SharedItemsActivity.kt | 17 +++++------ .../ThreadsOverviewActivity.kt | 18 +++++------ .../talk/translate/ui/TranslateActivity.kt | 14 ++++----- 13 files changed, 89 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 202d7aa61ec..7292f901894 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -92,6 +92,7 @@ import androidx.media3.session.SessionToken import androidx.recyclerview.widget.LinearLayoutManager import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -130,7 +131,6 @@ import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User @@ -299,14 +299,12 @@ class ChatActivity : */ private lateinit var initialUser: User - val chatViewModel: ChatViewModel by viewModels { - ViewModelFactoryWithParams(ChatViewModel::class.java) { - chatViewModelFactory.build( - initialUser, - roomToken, - conversationThreadId - ) - } + val chatViewModel: ChatViewModel by assistedViewModels { + chatViewModelFactory.build( + initialUser, + roomToken, + conversationThreadId + ) } lateinit var conversationInfoViewModel: ConversationInfoViewModel diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index 4589afc536a..b1c2a71bdb6 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -81,7 +81,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.widget.addTextChangedListener import androidx.emoji2.widget.EmojiEditText -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.R @@ -92,6 +91,7 @@ import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.ui.model.toScheduledMessageUiModel import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero @@ -125,12 +125,14 @@ private const val STICKY_HEADER_SCROLL_DELAY = 1200L class ScheduledMessagesActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ScheduledMessagesViewModel.Factory @Inject lateinit var dateUtils: DateUtils - private lateinit var scheduledMessagesViewModel: ScheduledMessagesViewModel + private val scheduledMessagesViewModel: ScheduledMessagesViewModel by assistedViewModels { + viewModelFactory.build(conversationUser) + } private lateinit var conversationUser: User @@ -165,8 +167,6 @@ class ScheduledMessagesActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) conversationUser = setUpBoundUserOrFinish() ?: return - scheduledMessagesViewModel = ViewModelProvider(this, viewModelFactory)[ScheduledMessagesViewModel::class.java] - scheduledMessagesViewModel.setUser(conversationUser) setContent { val colorScheme = viewThemeUtils.getColorScheme(this) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt index 24186a39689..bd4b18f6539 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt @@ -17,6 +17,9 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.opengraph.ReferenceDto import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.message.SendMessageUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -25,11 +28,11 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -class ScheduledMessagesViewModel @Inject constructor( +class ScheduledMessagesViewModel @AssistedInject constructor( private val chatRepository: ChatMessageRepository, - private val chatNetworkDataSource: ChatNetworkDataSource + private val chatNetworkDataSource: ChatNetworkDataSource, + @Assisted private val user: User ) : ViewModel() { sealed interface GetScheduledMessagesState @@ -51,8 +54,6 @@ class ScheduledMessagesViewModel @Inject constructor( data class ScheduledMessageErrorState(val error: Throwable? = null) : ScheduledMessageActionState - private lateinit var user: User - sealed interface SendNowMessageState object SendNowMessageIdleState : SendNowMessageState object SendNowMessageLoadingState : SendNowMessageState @@ -139,10 +140,6 @@ class ScheduledMessagesViewModel @Inject constructor( } } - fun setUser(user: User) { - this.user = user - } - @Suppress("LongParameterList") fun sendNow( credentials: String, @@ -268,6 +265,11 @@ class ScheduledMessagesViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): ScheduledMessagesViewModel + } + companion object { private val TAG = ScheduledMessagesViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index 650b41ec700..cddc0a37914 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -13,14 +13,14 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.utils.bundle.BundleKeys @@ -32,17 +32,15 @@ class ContactsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ContactsViewModel.Factory - private lateinit var contactsViewModel: ContactsViewModel + private lateinit var user: User + + private val contactsViewModel: ContactsViewModel by assistedViewModels { viewModelFactory.build(user) } @SuppressLint("UnrememberedMutableState") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return - contactsViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(ContactsViewModel::class.java) { viewModelFactory.build(user) } - )[ContactsViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return setContent { val isAddParticipants = intent.getBooleanExtra(BundleKeys.KEY_ADD_PARTICIPANTS, false) val hideAlreadyAddedParticipants = intent.getBooleanExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, false) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 14e217a1792..ab6dbd4998d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -83,7 +83,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import coil.compose.AsyncImage @@ -101,7 +100,7 @@ import com.nextcloud.talk.conversationcreation.ui.CreationResultEffect import com.nextcloud.talk.conversationcreation.ui.ShareCreatedConversation import com.nextcloud.talk.conversationcreation.ui.openConversation import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto @@ -120,17 +119,15 @@ class ConversationCreationActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ConversationCreationViewModel.Factory + private lateinit var user: User + + private val conversationCreationViewModel by assistedViewModels { viewModelFactory.build(user) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return - val conversationCreationViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(ConversationCreationViewModel::class.java) { - viewModelFactory.build(user) - } - )[ConversationCreationViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 4fe83736efe..ad067353cbc 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -31,11 +31,10 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.OneTimeWorkRequest -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -152,11 +151,15 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var contactsViewModelFactory: ContactsViewModel.Factory - lateinit var contactsViewModel: ContactsViewModel + val contactsViewModel: ContactsViewModel by assistedViewModels { contactsViewModelFactory.build(currentUser!!) } - lateinit var conversationsListViewModel: ConversationsListViewModel + val conversationsListViewModel: ConversationsListViewModel by assistedViewModels { + conversationsListViewModelFactory.build(currentUser!!) + } - lateinit var conversationTagsViewModel: ConversationTagsViewModel + val conversationTagsViewModel: ConversationTagsViewModel by assistedViewModels { + conversationTagsViewModelFactory.build(currentUser!!) + } private var currentUser: User? = null private val snackbarHostState = SnackbarHostState() @@ -211,23 +214,6 @@ class ConversationsListActivity : BaseActivity() { lifecycleScope.launch { userManager.setUserAsActive(user) } } - conversationsListViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(ConversationsListViewModel::class.java) { - conversationsListViewModelFactory.build(user) - } - )[ConversationsListViewModel::class.java] - conversationTagsViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(ConversationTagsViewModel::class.java) { - conversationTagsViewModelFactory.build(user) - } - )[ConversationTagsViewModel::class.java] - contactsViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(ContactsViewModel::class.java) { contactsViewModelFactory.build(user) } - )[ContactsViewModel::class.java] - setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) if (savedInstanceState != null) { diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 111bf0f6402..779663a7afa 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -7,12 +7,13 @@ */ package com.nextcloud.talk.dagger.modules +import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewViewModel -import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel @@ -53,6 +54,13 @@ class ViewModelFactoryWithParams(private val modelClass: Class } } +/** + * Lazily creates a view model with [create] on first access, scoped to this activity like [viewModels]. For view + * models that take arguments, e.g. via assisted injection. Everything [create] uses must be set before that access. + */ +inline fun ComponentActivity.assistedViewModels(noinline create: () -> VM): Lazy = + viewModels { ViewModelFactoryWithParams(VM::class.java, create) } + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) @Retention(AnnotationRetention.RUNTIME) @MapKey @@ -165,11 +173,6 @@ abstract class ViewModelModule { @ViewModelKey(StatusMessageViewModel::class) abstract fun statusMessageViewModel(viewModel: StatusMessageViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ScheduledMessagesViewModel::class) - abstract fun scheduledMessagesViewModel(viewModel: ScheduledMessagesViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(ChooseAccountShareToViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index 024eccb99fa..920583a2148 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.nextcloud.talk.R @@ -39,8 +38,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.errorhandling.saveLogsAsZip import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -71,7 +71,9 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var logsRepository: LogsRepository - private lateinit var diagnosisViewModel: DiagnosisViewModel + private lateinit var user: User + + private val diagnosisViewModel: DiagnosisViewModel by assistedViewModels { viewModelFactory.build(user) } private val diagnosisData = mutableListOf() private val diagnosisDataState = mutableStateOf(emptyList()) @@ -90,11 +92,7 @@ class DiagnosisActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return - diagnosisViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(DiagnosisViewModel::class.java) { viewModelFactory.build(user) } - )[DiagnosisViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return val colorScheme = viewThemeUtils.getColorScheme(this) val isGooglePlayServicesAvailable = ClosedInterfaceImpl().isGooglePlayServicesAvailable diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index d82fe69f5d9..ec1c3888501 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -11,14 +11,14 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.utils.adjustUIForAPILevel35 @@ -30,18 +30,18 @@ class ListOpenConversationsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: OpenConversationsViewModel.Factory - private lateinit var openConversationsViewModel: OpenConversationsViewModel + private lateinit var user: User + + private val openConversationsViewModel: OpenConversationsViewModel by assistedViewModels { + viewModelFactory.build(user) + } override fun onCreate(savedInstanceState: Bundle?) { adjustUIForAPILevel35() super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return + user = setUpBoundUserOrFinish() ?: return - openConversationsViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(OpenConversationsViewModel::class.java) { viewModelFactory.build(user) } - )[OpenConversationsViewModel::class.java] openConversationsViewModel.fetchConversations() setContent { diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index 1e403cfd610..b1ab33ecf44 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -22,14 +22,13 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment -import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityRemoteFileBrowserBinding import com.nextcloud.talk.remotefilebrowser.SelectionInterface @@ -71,7 +70,7 @@ class RemoteFileBrowserActivity : lateinit var dateUtils: DateUtils private lateinit var binding: ActivityRemoteFileBrowserBinding - private lateinit var viewModel: RemoteFileBrowserItemsViewModel + private val viewModel: RemoteFileBrowserItemsViewModel by assistedViewModels { viewModelFactory.build(user) } private var filesSelectionDoneMenuItem: MenuItem? = null @@ -133,11 +132,6 @@ class RemoteFileBrowserActivity : } private fun initViewModel(mimeTypeSelectionFilter: String?) { - viewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(RemoteFileBrowserItemsViewModel::class.java) { viewModelFactory.build(user) } - )[RemoteFileBrowserItemsViewModel::class.java] - viewModel.viewState.observe(this) { state -> clearEmptyLoading() when (state) { diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 30bf635c74b..43103eb799c 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -13,7 +13,6 @@ import android.os.Bundle import android.util.Log import android.view.MenuItem import android.view.View -import androidx.activity.viewModels import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -25,7 +24,7 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySharedItemsBinding import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter @@ -63,14 +62,12 @@ class SharedItemsActivity : BaseActivity() { } } - val chatViewModel: ChatViewModel by viewModels { - ViewModelFactoryWithParams(ChatViewModel::class.java) { - chatViewModelFactory.build( - conversationUser, - roomToken, - conversationThreadId - ) - } + val chatViewModel: ChatViewModel by assistedViewModels { + chatViewModelFactory.build( + conversationUser, + roomToken, + conversationThreadId + ) } @Inject diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index bdcfd479e21..18280571ac0 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity @@ -44,8 +43,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.database.mappers.toDomainModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.threads.ThreadInfoDto import com.nextcloud.talk.threadsoverview.components.ThreadRow import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel @@ -61,7 +61,11 @@ class ThreadsOverviewActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - lateinit var threadsOverviewViewModel: ThreadsOverviewViewModel + private lateinit var user: User + + val threadsOverviewViewModel: ThreadsOverviewViewModel by assistedViewModels { + threadsOverviewViewModelFactory.build(user) + } var threadsSourceUrl: String = "" var appbarTitle: String = "" @@ -69,13 +73,7 @@ class ThreadsOverviewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return - threadsOverviewViewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(ThreadsOverviewViewModel::class.java) { - threadsOverviewViewModelFactory.build(user) - } - )[ThreadsOverviewViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return val colorScheme = viewThemeUtils.getColorScheme(this) diff --git a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt index 63a543e5b23..9b6b83763d9 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt @@ -18,13 +18,13 @@ import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import androidx.core.graphics.drawable.toDrawable -import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityTranslateBinding import com.nextcloud.talk.translate.repositories.model.LanguageDto import com.nextcloud.talk.translate.viewmodels.TranslateViewModel @@ -39,7 +39,9 @@ class TranslateActivity : BaseActivity() { @Inject lateinit var viewModelFactory: TranslateViewModel.Factory - lateinit var viewModel: TranslateViewModel + private lateinit var user: User + + val viewModel: TranslateViewModel by assistedViewModels { viewModelFactory.build(user) } lateinit var binding: ActivityTranslateBinding private var toLanguages: Array? = null @@ -50,13 +52,9 @@ class TranslateActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return + user = setUpBoundUserOrFinish() ?: return binding = ActivityTranslateBinding.inflate(layoutInflater) - viewModel = ViewModelProvider( - this, - ViewModelFactoryWithParams(TranslateViewModel::class.java) { viewModelFactory.build(user) } - )[TranslateViewModel::class.java] viewModel.viewState.observe(this) { state -> when (state) { From b8569fb7af9a9694181deaeb52782b6710f16ac1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 07:54:30 +0200 Subject: [PATCH 29/76] fix(chat): don't access the chat view model when restoring the message input After process death, MessageInputFragment is restored during ChatActivity's super.onCreate(), before the activity can create its view model. With shared text in its arguments, the fragment's onCreate() accessed that view model and crashed. ChatActivity now puts the shared text into the draft itself, and the fragment only reads whether there is one. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/chat/ChatActivity.kt | 4 ++++ .../java/com/nextcloud/talk/chat/MessageInputFragment.kt | 9 +++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 7292f901894..8594c11831a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -649,6 +649,10 @@ class ChatActivity : handleIntent(intent) chatViewModel.initData() + if (sharedText.isNotEmpty()) { + chatViewModel.messageDraft.messageText = sharedText + chatViewModel.saveMessageDraft() + } conversationThreadId?.let { val threadUrl = ApiUtils.getUrlForThread( diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 11fb8297af0..a5d3fa86c7b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -141,12 +141,9 @@ class MessageInputFragment : Fragment() { viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) conversationInternalId = arguments?.getString(ChatActivity.CONVERSATION_INTERNAL_ID).orEmpty() chatActivity = requireActivity() as ChatActivity - val sharedText = arguments?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() - if (sharedText.isNotEmpty()) { - hasSharedText = true - chatActivity.chatViewModel.messageDraft.messageText = sharedText - chatActivity.chatViewModel.saveMessageDraft() - } + // The shared text itself is put into the draft by ChatActivity. This fragment may be restored before + // ChatActivity has created its view model, so it must not access it here. + hasSharedText = arguments?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty().isNotEmpty() if (conversationInternalId.isEmpty()) { Log.e(TAG, "internalId for conversation passed to MessageInputFragment is empty") } From 8d0d03a81fc59b8bb804d2ecce3491a109040133 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 07:59:29 +0200 Subject: [PATCH 30/76] fix(theming): theme restored fragments with the account of their activity Fragments restored in super.onCreate() took over the activity's viewThemeUtils before the activity switched to the theme of its account, so after rotation or process death they could keep the colors of the default account. BaseActivity now applies the account theme before super.onCreate() when the intent carries an account, and only once, so all fragments share the same instance. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 01a57500fd9..18751f85885 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -150,12 +150,20 @@ open class BaseActivity : AppCompatActivity() { null } + private var isUserThemeApplied = false + /** - * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. - * Must be called after injection and before any view is themed. + * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. Applied + * only once, so fragments that already took over [viewThemeUtils] keep the same instance. */ private fun applyUserTheme() { - boundUser?.let { viewThemeUtils = viewThemeUtilsFactory.forUser(it) } + if (isUserThemeApplied) { + return + } + boundUser?.let { + viewThemeUtils = viewThemeUtilsFactory.forUser(it) + isUserThemeApplied = true + } } /** @@ -198,6 +206,11 @@ open class BaseActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) logger.i(this::class.java.simpleName, "onCreate") + // Fragments restored in super.onCreate() take over viewThemeUtils there, so apply the theme of the account + // before. Without an account in the intent, the injected theme of the default account is the right one. + if (intent.hasExtra(BundleKeys.KEY_INTERNAL_USER_ID)) { + applyUserTheme() + } adjustUIForAPILevel35() super.onCreate(savedInstanceState) From c3cd19d76c623aaf9c4cc99d526f51b7f79e64fa Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 08:06:22 +0200 Subject: [PATCH 31/76] refactor: remove unused MessageSearchActivity The activity isn't started from anywhere anymore, since the message search moved into the chat. Remove it together with its view model, adapter items, layouts, menu and the resources only it used. MessageSearchHelper is still used by the chat and the conversation list. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 4 - .../adapters/items/LoadMoreResultsItem.kt | 53 ---- .../talk/adapters/items/MessageResultItem.kt | 89 ------- .../talk/dagger/modules/ViewModelModule.kt | 6 - .../messagesearch/MessageSearchActivity.kt | 250 ------------------ .../messagesearch/MessageSearchViewModel.kt | 120 --------- .../talk/utils/rx/SearchViewObservable.kt | 35 --- .../res/layout/activity_message_search.xml | 59 ----- app/src/main/res/layout/rv_item_load_more.xml | 42 --- .../res/layout/rv_item_search_message.xml | 60 ----- app/src/main/res/menu/menu_search.xml | 16 -- app/src/main/res/values/dimens.xml | 1 - app/src/main/res/values/strings.xml | 2 - 13 files changed, 737 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt delete mode 100644 app/src/main/res/layout/activity_message_search.xml delete mode 100644 app/src/main/res/layout/rv_item_load_more.xml delete mode 100644 app/src/main/res/layout/rv_item_search_message.xml delete mode 100644 app/src/main/res/menu/menu_search.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba8c0504401..3923ae833d7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -233,10 +233,6 @@ android:name=".remotefilebrowser.activities.RemoteFileBrowserActivity" android:theme="@style/AppTheme" /> - - diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt deleted file mode 100644 index f47aec87f85..00000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters.items - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.talk.R -import com.nextcloud.talk.databinding.RvItemLoadMoreBinding -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFilterable -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.viewholders.FlexibleViewHolder - -object LoadMoreResultsItem : - AbstractFlexibleItem(), - IFilterable { - - // layout is used as view type for uniqueness - const val VIEW_TYPE = FlexibleItemViewType.LOAD_MORE_RESULTS_ITEM - - class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - var binding: RvItemLoadMoreBinding = RvItemLoadMoreBinding.bind(view) - } - - override fun getLayoutRes(): Int = R.layout.rv_item_load_more - - override fun createViewHolder( - view: View, - adapter: FlexibleAdapter> - ): ViewHolder = ViewHolder(view, adapter) - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ViewHolder, - position: Int, - payloads: MutableList? - ) { - // nothing, it's immutable - } - - override fun filter(constraint: String?): Boolean = true - - override fun getItemViewType(): Int = VIEW_TYPE - - override fun equals(other: Any?): Boolean = other is LoadMoreResultsItem - - override fun hashCode(): Int = 0 -} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt deleted file mode 100644 index cbd91124215..00000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Tim Krüger - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017-2019 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters.items - -import android.content.Context -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.talk.R -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.RvItemSearchMessageBinding -import com.nextcloud.talk.extensions.loadThumbnail -import com.nextcloud.talk.models.domain.SearchMessageEntry -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFilterable -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.flexibleadapter.items.ISectionable -import eu.davidea.viewholders.FlexibleViewHolder - -data class MessageResultItem( - private val context: Context, - private val currentUser: User, - val messageEntry: SearchMessageEntry, - var showHeader: Boolean = false, - private val viewThemeUtils: ViewThemeUtils -) : AbstractFlexibleItem(), - IFilterable, - ISectionable { - - class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - var binding: RvItemSearchMessageBinding - - init { - binding = RvItemSearchMessageBinding.bind(view) - } - } - - override fun getLayoutRes(): Int = R.layout.rv_item_search_message - - override fun createViewHolder( - view: View, - adapter: FlexibleAdapter> - ): ViewHolder = ViewHolder(view, adapter) - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ViewHolder, - position: Int, - payloads: MutableList? - ) { - holder.binding.conversationTitle.text = messageEntry.title - bindMessageExcerpt(holder) - messageEntry.thumbnailURL?.let { holder.binding.thumbnail.loadThumbnail(it, currentUser) } - } - - private fun bindMessageExcerpt(holder: ViewHolder) { - viewThemeUtils.platform.highlightText( - holder.binding.messageExcerpt, - messageEntry.messageExcerpt, - messageEntry.searchTerm - ) - } - - override fun filter(constraint: String?): Boolean = true - - override fun getItemViewType(): Int = VIEW_TYPE - - companion object { - const val VIEW_TYPE = FlexibleItemViewType.MESSAGE_RESULT_ITEM - } - - override fun getHeader(): GenericTextHeaderItem = - MessagesTextHeaderItem(context, viewThemeUtils) - .apply { - isHidden = showHeader // FlexibleAdapter needs this hack for some reason - } - - override fun setHeader(header: GenericTextHeaderItem?) { - // nothing, header is always the same - } -} diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 779663a7afa..43f7cac8777 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -22,7 +22,6 @@ import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditVie import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel -import com.nextcloud.talk.messagesearch.MessageSearchViewModel import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToViewModel import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.polls.viewmodels.PollMainViewModel @@ -83,11 +82,6 @@ abstract class ViewModelModule { @ViewModelKey(MediaViewerViewModel::class) abstract fun mediaViewerViewModel(viewModel: MediaViewerViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(MessageSearchViewModel::class) - abstract fun messageSearchViewModel(viewModel: MessageSearchViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(PollMainViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt deleted file mode 100644 index fb343776751..00000000000 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.messagesearch - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.text.TextUtils -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.SearchView -import androidx.lifecycle.ViewModelProvider -import autodagger.AutoInjector -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.talk.R -import com.nextcloud.talk.activities.BaseActivity -import com.nextcloud.talk.adapters.items.LoadMoreResultsItem -import com.nextcloud.talk.adapters.items.MessageResultItem -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.conversationlist.ConversationsListActivity -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityMessageSearchBinding -import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.viewholders.FlexibleViewHolder -import io.reactivex.Observable -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class MessageSearchActivity : BaseActivity() { - - @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory - - private lateinit var binding: ActivityMessageSearchBinding - private lateinit var searchView: SearchView - - private lateinit var user: User - - private lateinit var viewModel: MessageSearchViewModel - - private var searchViewDisposable: Disposable? = null - private var adapter: FlexibleAdapter>? = null - - private val onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - setResult(Activity.RESULT_CANCELED) - finish() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - user = setUpBoundUserOrFinish() ?: return - - binding = ActivityMessageSearchBinding.inflate(layoutInflater) - setupActionBar() - setContentView(binding.root) - initSystemBars() - - viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] - val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! - viewModel.initialize(user, roomToken) - setupStateObserver() - - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh(searchView.query?.toString()) - } - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - private fun setupActionBar() { - setSupportActionBar(binding.messageSearchToolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val conversationName = intent.getStringExtra(BundleKeys.KEY_CONVERSATION_NAME) - supportActionBar?.title = conversationName - viewThemeUtils.material.themeToolbar(binding.messageSearchToolbar) - } - - private fun setupStateObserver() { - viewModel.state.observe(this) { state -> - when (state) { - MessageSearchViewModel.InitialState -> showInitial() - MessageSearchViewModel.EmptyState -> showEmpty() - is MessageSearchViewModel.LoadedState -> showLoaded(state) - MessageSearchViewModel.LoadingState -> showLoading() - MessageSearchViewModel.ErrorState -> showError() - is MessageSearchViewModel.FinishedState -> onFinish() - } - } - } - - private fun showError() { - displayLoading(false) - Snackbar.make(binding.root, "Error while searching", Snackbar.LENGTH_SHORT).show() - } - - private fun showLoading() { - displayLoading(true) - } - - private fun displayLoading(loading: Boolean) { - binding.swipeRefreshLayout.isRefreshing = loading - } - - private fun showLoaded(state: MessageSearchViewModel.LoadedState) { - displayLoading(false) - binding.emptyContainer.emptyListView.visibility = View.GONE - binding.messageSearchRecycler.visibility = View.VISIBLE - setAdapterItems(state) - } - - private fun setAdapterItems(state: MessageSearchViewModel.LoadedState) { - val loadMoreItems = if (state.hasMore) { - listOf(LoadMoreResultsItem) - } else { - emptyList() - } - val newItems = - state.results.map { MessageResultItem(this, user, it, false, viewThemeUtils) } + loadMoreItems - - if (adapter != null) { - adapter!!.updateDataSet(newItems) - } else { - createAdapter(newItems) - } - } - - private fun createAdapter(items: List>) { - adapter = FlexibleAdapter(items) - binding.messageSearchRecycler.adapter = adapter - adapter!!.addListener(object : FlexibleAdapter.OnItemClickListener { - override fun onItemClick(view: View?, position: Int): Boolean { - val item = adapter!!.getItem(position) - when (item) { - is LoadMoreResultsItem -> { - viewModel.loadMore() - } - is MessageResultItem -> { - viewModel.selectMessage(item.messageEntry) - } - } - return false - } - }) - } - - private fun onFinish() { - val state = viewModel.state.value - if (state is MessageSearchViewModel.FinishedState) { - val resultIntent = Intent().apply { - putExtra(RESULT_KEY_MESSAGE_ID, state.selectedMessageId) - putExtra(RESULT_KEY_THREAD_ID, state.selectedThreadId) - } - setResult(Activity.RESULT_OK, resultIntent) - finish() - } - } - - private fun showInitial() { - displayLoading(false) - binding.messageSearchRecycler.visibility = View.GONE - binding.emptyContainer.emptyListViewHeadline.text = getString(R.string.message_search_begin_typing) - binding.emptyContainer.emptyListView.visibility = View.VISIBLE - } - - private fun showEmpty() { - displayLoading(false) - binding.messageSearchRecycler.visibility = View.GONE - binding.emptyContainer.emptyListViewHeadline.text = getString(R.string.message_search_begin_empty) - binding.emptyContainer.emptyListView.visibility = View.VISIBLE - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_search, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - val menuItem = menu.findItem(R.id.action_search) - searchView = menuItem.actionView as SearchView - setupSearchView() - menuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - searchView.requestFocus() - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - onBackPressedDispatcher.onBackPressed() - return false - } - }) - menuItem.expandActionView() - return true - } - - private fun setupSearchView() { - searchView.queryHint = getString(R.string.message_search_hint) - searchViewDisposable = observeSearchView(searchView) - .debounce { query -> - when { - TextUtils.isEmpty(query) -> Observable.empty() - else -> Observable.timer( - ConversationsListActivity.SEARCH_DEBOUNCE_INTERVAL_MS.toLong(), - TimeUnit.MILLISECONDS - ) - } - } - .distinctUntilChanged() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { newText -> viewModel.onQueryTextChange(newText) } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - - override fun onDestroy() { - super.onDestroy() - searchViewDisposable?.dispose() - } - - companion object { - const val RESULT_KEY_MESSAGE_ID = "MessageSearchActivity.result.message" - const val RESULT_KEY_THREAD_ID = "MessageSearchActivity.result.thread" - } -} diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt deleted file mode 100644 index eea7400cd7d..00000000000 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.messagesearch - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.logger.Logger -import com.nextcloud.talk.models.domain.SearchMessageEntry -import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * Install PlantUML plugin to render this state diagram - * @startuml - * hide empty description - * [*] --> InitialState - * InitialState --> LoadingState - * LoadingState --> EmptyState - * LoadingState --> LoadedState - * LoadingState --> LoadingState - * LoadedState --> LoadingState - * EmptyState --> LoadingState - * LoadingState --> ErrorState - * ErrorState --> LoadingState - * @enduml - */ -class MessageSearchViewModel @Inject constructor( - private val unifiedSearchRepository: UnifiedSearchRepository, - private val logger: Logger -) : ViewModel() { - - sealed class ViewState - object InitialState : ViewState() - object LoadingState : ViewState() - object EmptyState : ViewState() - object ErrorState : ViewState() - class LoadedState(val results: List, val hasMore: Boolean) : ViewState() - class FinishedState(val selectedMessageId: String, val selectedThreadId: String?) : ViewState() - - private lateinit var messageSearchHelper: MessageSearchHelper - - private val _state: MutableLiveData = MutableLiveData(InitialState) - val state: LiveData - get() = _state - - private var searchJob: Job? = null - - fun initialize(user: User, roomToken: String) { - messageSearchHelper = MessageSearchHelper( - unifiedSearchRepository, - user, - roomToken - ) - } - - @Suppress("Detekt.TooGenericExceptionCaught") - fun onQueryTextChange(newText: String) { - if (newText.length >= MIN_CHARS_FOR_SEARCH) { - _state.value = LoadingState - searchJob?.cancel() - searchJob = viewModelScope.launch { - try { - val results = messageSearchHelper.startMessageSearch(newText) - onReceiveResults(results) - } catch (e: Exception) { - onError(e) - } - } - } - } - - fun loadMore() { - _state.value = LoadingState - searchJob?.cancel() - searchJob = viewModelScope.launch { - try { - val results = messageSearchHelper.loadMore() - results?.let { onReceiveResults(it) } - } catch (e: Exception) { - onError(e) - } - } - } - - private fun onReceiveResults(results: MessageSearchHelper.MessageSearchResults) { - if (results.messages.isEmpty()) { - _state.value = EmptyState - } else { - _state.value = LoadedState(results.messages, results.hasMore) - } - } - - private fun onError(throwable: Throwable) { - logger.e(TAG, "onError:", throwable) - _state.value = ErrorState - } - - fun refresh(query: String?) { - query?.let { onQueryTextChange(it) } - } - - fun selectMessage(messageEntry: SearchMessageEntry) { - _state.value = FinishedState(messageEntry.messageId!!, messageEntry.threadId) - } - - companion object { - private val TAG = MessageSearchViewModel::class.java.simpleName - private const val MIN_CHARS_FOR_SEARCH = 2 - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt b/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt deleted file mode 100644 index 5097ff84655..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.rx - -import androidx.appcompat.widget.SearchView -import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject - -class SearchViewObservable { - - companion object { - @JvmStatic - fun observeSearchView(searchView: SearchView): Observable { - val subject: PublishSubject = PublishSubject.create() - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - searchView.clearFocus() - subject.onComplete() - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - subject.onNext(newText) - return true - } - }) - return subject - } - } -} diff --git a/app/src/main/res/layout/activity_message_search.xml b/app/src/main/res/layout/activity_message_search.xml deleted file mode 100644 index e077575275d..00000000000 --- a/app/src/main/res/layout/activity_message_search.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/rv_item_load_more.xml b/app/src/main/res/layout/rv_item_load_more.xml deleted file mode 100644 index 7e05fe57bae..00000000000 --- a/app/src/main/res/layout/rv_item_load_more.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/rv_item_search_message.xml b/app/src/main/res/layout/rv_item_search_message.xml deleted file mode 100644 index 020d276c5cf..00000000000 --- a/app/src/main/res/layout/rv_item_search_message.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/menu_search.xml deleted file mode 100644 index 970712def42..00000000000 --- a/app/src/main/res/menu/menu_search.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index ca9a240d299..ba77a21a5e5 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -21,7 +21,6 @@ 8dp - 16dp 40dp 96dp 52dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6022720eb53..44829e5b7bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -885,8 +885,6 @@ How to translate with transifex: Messages Load more results Search … - Start typing to search … - No search results Previous search result Next search result No more messages found From 346bf0163580cac269c17d1e5aafcc5ebc365037 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 08:23:29 +0200 Subject: [PATCH 32/76] fix: create injected view models as real view models BrowserLoginActivity, SharedItemsActivity and the account dialog injected their view models directly as fields, so the view models were not kept across configuration changes and never cleared, which left their coroutines running. Create them through the view model factory instead, scoped to their activity. Also remove commented-out code from ViewModelModule. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/account/BrowserLoginActivity.kt | 6 +++++- .../chooseaccount/ChooseAccountDialogCompose.kt | 14 +++++++------- .../talk/dagger/modules/ViewModelModule.kt | 13 ------------- .../shareditems/activities/SharedItemsActivity.kt | 4 ++-- 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt index 61ee119f85f..00ddd498d96 100644 --- a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt @@ -14,8 +14,10 @@ import android.content.Intent import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels import androidx.core.net.toUri import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector @@ -39,7 +41,9 @@ class BrowserLoginActivity : BaseActivity() { private lateinit var binding: ActivityWebViewLoginBinding @Inject - lateinit var viewModel: BrowserLoginActivityViewModel + lateinit var viewModelFactory: ViewModelProvider.Factory + + private val viewModel: BrowserLoginActivityViewModel by viewModels { viewModelFactory } private var reauthorizeAccount = false private val onBackPressedCallback = object : OnBackPressedCallback(true) { diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index d3501e8ba90..fafb9c92b1d 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -65,7 +65,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.core.net.toUri +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import autodagger.AutoInjector import coil.compose.AsyncImage import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp @@ -119,13 +121,7 @@ class ChooseAccountDialogCompose { lateinit var viewThemeUtils: ViewThemeUtils @Inject - lateinit var invitationsViewModel: InvitationsViewModel - - @Inject - lateinit var statusViewModel: StatusViewModel - - @Inject - lateinit var statusMessageViewModel: StatusMessageViewModel + lateinit var viewModelFactory: ViewModelProvider.Factory @Inject lateinit var networkMonitor: NetworkMonitor @@ -138,6 +134,10 @@ class ChooseAccountDialogCompose { @Suppress("LongMethod") fun GetChooseAccountDialog(shouldDismiss: MutableState, activity: Activity, showEcosystem: Boolean) { if (shouldDismiss.value) return + // Scoped to the hosting activity, so they survive configuration changes and are cleared with it. + val invitationsViewModel: InvitationsViewModel = viewModel(factory = viewModelFactory) + val statusViewModel: StatusViewModel = viewModel(factory = viewModelFactory) + val statusMessageViewModel: StatusMessageViewModel = viewModel(factory = viewModelFactory) val colorScheme = viewThemeUtils.getColorScheme(activity) val status = remember { mutableStateOf(null) } val showOnlineStatusSheet = rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 43f7cac8777..d5298027e64 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -117,11 +117,6 @@ abstract class ViewModelModule { @ViewModelKey(RaiseHandViewModel::class) abstract fun raiseHandViewModel(viewModel: RaiseHandViewModel): ViewModel - // @Binds - // @IntoMap - // @ViewModelKey(ChatViewModel::class) - // abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(ConversationInfoViewModel::class) @@ -177,11 +172,3 @@ abstract class ViewModelModule { @ViewModelKey(FileAttachmentPreviewViewModel::class) internal abstract fun fileAttachmentPreviewViewModel(viewModel: FileAttachmentPreviewViewModel): ViewModel } - -// @Module -// interface ChatViewModelAssistedModule { -// @Binds -// fun bindChatViewModelFactory( -// factory: ChatViewModel.Factory -// ): ChatViewModel.Factory -// } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index 43103eb799c..a507bac38f9 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -13,6 +13,7 @@ import android.os.Bundle import android.util.Log import android.view.MenuItem import android.view.View +import androidx.activity.viewModels import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -70,8 +71,7 @@ class SharedItemsActivity : BaseActivity() { ) } - @Inject - lateinit var contextChatViewModel: ContextChatViewModel + private val contextChatViewModel: ContextChatViewModel by viewModels { viewModelFactory } private lateinit var binding: ActivitySharedItemsBinding private lateinit var viewModel: SharedItemsViewModel From 01febafa3b94d66385872be408f1bf912d011902 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 08:23:31 +0200 Subject: [PATCH 33/76] fix(status): keep an edited status message across rotation The status message sheet initialized its view model with the stored status every time it was composed, so after a rotation an unsaved edit was replaced by the stored status. Initialize it only once per opening of the sheet. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/chooseaccount/ui/StatusMessageSheet.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt index 1a26070a89c..cf1ff7eb173 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt @@ -60,10 +60,16 @@ fun StatusMessageModalBottomSheet(currentStatus: StatusDto, viewModel: StatusMes var showEmojiPicker by rememberSaveable { mutableStateOf(false) } val backToStatus: () -> Unit = { showEmojiPicker = false } - LaunchedEffect(currentStatus) { - viewModel.init(currentStatus) - viewModel.checkBackupStatus() - viewModel.fetchPredefinedStatuses() + // The view model keeps unsaved edits across configuration changes, so it is only initialized when the sheet is + // opened, not again when it is recreated. + var isInitialized by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (!isInitialized) { + viewModel.init(currentStatus) + viewModel.checkBackupStatus() + viewModel.fetchPredefinedStatuses() + isInitialized = true + } } LaunchedEffect(isDismissed) { From 1cb610a7a4155bbe7428a3d52924e416eccdcba1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 09:23:33 +0200 Subject: [PATCH 34/76] feat(call): make the account of a joined call the default account Taking part in a call is an active use of its account, like opening one of its notifications, so it becomes the last used account. A call that only rings or is declined doesn't change it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/activities/CallActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 252d4d91b10..3a443ee742c 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -59,6 +59,7 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.bluelinelabs.logansquare.LoganSquare import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -548,6 +549,11 @@ class CallActivity : CallBaseActivity() { isCallSetUp = true + if (!conversationUser.current) { + // Taking part in a call uses its account, so it becomes the last used one. + lifecycleScope.launch { userManager.setUserAsActive(conversationUser) } + } + setCallState(CallStatus.CONNECTING) initRaiseHandViewModel() From b8d66d1772a11978446c88cb4132e9dd2ea24498 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 10:07:34 +0200 Subject: [PATCH 35/76] fix(theming): don't lose the account theme to the subclass injection The injection of a subclass also injects BaseActivity's viewThemeUtils and replaced the account theme with the one of the default account. Since the theme was only applied once, screens of a non-default account (e.g. an incoming call) were shown with the default account's colors. Keep the account theme and assign it again after the subclass injection. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 18751f85885..802ba39b50a 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -150,20 +150,18 @@ open class BaseActivity : AppCompatActivity() { null } - private var isUserThemeApplied = false + private var userViewThemeUtils: ViewThemeUtils? = null /** - * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. Applied - * only once, so fragments that already took over [viewThemeUtils] keep the same instance. + * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. The theme + * is created once and assigned again on every call, because the injection of a subclass also injects + * [viewThemeUtils] and overwrites it with the theme of the default account. Reusing the instance keeps fragments + * that already took it over on the same one. */ private fun applyUserTheme() { - if (isUserThemeApplied) { - return - } - boundUser?.let { - viewThemeUtils = viewThemeUtilsFactory.forUser(it) - isUserThemeApplied = true - } + val themeUtils = userViewThemeUtils + ?: boundUser?.let { viewThemeUtilsFactory.forUser(it) }?.also { userViewThemeUtils = it } + themeUtils?.let { viewThemeUtils = it } } /** From 184511d0a67bde752c81e117b55b8a6f80bebba0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 10:16:12 +0200 Subject: [PATCH 36/76] fix(contacts): open a created conversation only once roomViewState kept its Success, so a recreated conversation list (e.g. after rotation) received it again and reopened the chat, now that ContactsViewModel is kept across configuration changes. In the contacts screen, every contact row opened the chat on Success, during composition. Reset the state once it's handled, and open the chat from a single place in ContactsActivity. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/contacts/ContactsActivity.kt | 20 +++++++++++++++++++ .../talk/contacts/ContactsViewModel.kt | 8 ++++++++ .../contacts/components/ContactItemRow.kt | 12 ++--------- .../ConversationsListActivity.kt | 1 + .../talk/contacts/ContactsViewModelTest.kt | 9 +++++++++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index cddc0a37914..f2bb4b20bb3 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -9,14 +9,17 @@ package com.nextcloud.talk.contacts import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.dagger.modules.assistedViewModels @@ -24,6 +27,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -41,6 +45,7 @@ class ContactsActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) user = setUpBoundUserOrFinish() ?: return + observeCreatedRoom() setContent { val isAddParticipants = intent.getBooleanExtra(BundleKeys.KEY_ADD_PARTICIPANTS, false) val hideAlreadyAddedParticipants = intent.getBooleanExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, false) @@ -76,6 +81,21 @@ class ContactsActivity : BaseActivity() { } } } + + private fun observeCreatedRoom() { + lifecycleScope.launch { + contactsViewModel.roomViewState.collect { state -> + if (state is ContactsViewModel.RoomUiState.Success) { + state.conversation?.token?.let { token -> + val chatIntent = ChatActivity.createIntent(this@ContactsActivity, state.userId, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } + contactsViewModel.clearRoomState() + } + } + } + } } class CompanionClass { diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt index fef331fcec9..2dfc071829c 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Suppress("TooManyFunctions") class ContactsViewModel @AssistedInject constructor( private val repository: ContactsRepository, private val logger: Logger, @@ -169,6 +170,13 @@ class ContactsViewModel @AssistedInject constructor( } } + /** + * Resets [roomViewState] once its result was handled, so a recreated screen doesn't handle it again. + */ + fun clearRoomState() { + _roomViewState.value = RoomUiState.None + } + @Suppress("Detekt.TooGenericExceptionCaught") fun createRoom(roomType: String, sourceType: String?, userId: String, conversationName: String?) { val user = currentUser diff --git a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt index 65e8d6e08b9..db41618eae6 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt @@ -9,7 +9,6 @@ package com.nextcloud.talk.contacts.components import android.content.Context -import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -35,7 +34,6 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.nextcloud.talk.R -import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.contacts.CompanionClass import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contacts.loadImage @@ -96,14 +94,8 @@ fun ContactItemRow(contact: AutocompleteUserDto, contactsViewModel: ContactsView } } when (roomUiState) { - is ContactsViewModel.RoomUiState.Success -> { - val successState = roomUiState as ContactsViewModel.RoomUiState.Success - successState.conversation?.token?.let { token -> - val chatIntent = ChatActivity.createIntent(context, successState.userId, token) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - context.startActivity(chatIntent) - } - } + // Opening the created conversation is handled once by ContactsActivity, not by every row. + is ContactsViewModel.RoomUiState.Success -> {} is ContactsViewModel.RoomUiState.Error -> { val errorMessage = (roomUiState as ContactsViewModel.RoomUiState.Error).message Box( diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index ad067353cbc..a0358f1a056 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -550,6 +550,7 @@ class ConversationsListActivity : BaseActivity() { chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } + contactsViewModel.clearRoomState() } else -> {} diff --git a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt index cfc738520cd..76f9799ca7f 100644 --- a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt @@ -31,6 +31,7 @@ import org.junit.Test import org.mockito.kotlin.mock @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("TooManyFunctions") class ContactsViewModelTest { private lateinit var viewModel: ContactsViewModel private val repository: ContactsRepository = FakeRepositorySuccess() @@ -126,6 +127,14 @@ class ContactsViewModelTest { assert(successState.conversation == FakeItem.roomOverall.ocs!!.data) } + @Test + fun `a handled room state is cleared`() = + runTest { + viewModel.createRoom("1", "users", "s@gmail.com", null) + viewModel.clearRoomState() + assert(viewModel.roomViewState.value is ContactsViewModel.RoomUiState.None) + } + @Test fun `test failure room state`() = runTest { From 193807f9afd2e136cf685c82f21a9088f972dcce Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 10:20:55 +0200 Subject: [PATCH 37/76] fix(user): open links for the account of the screen BaseActivity decided with the default account whether a link points to the own server. A chat of another account therefore opened links to its server in the browser, or, with two accounts on the same server, opened the conversation as the default account. Use the account of the screen if it has one. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/BaseActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 802ba39b50a..b8d07425820 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -135,10 +135,11 @@ open class BaseActivity : AppCompatActivity() { * activities with [allowsDefaultAccount], a requested account that doesn't exist (anymore) falls back to the * default account. Loaded once on first access, which must be after injection. */ - private val boundUser: User? by lazy { + private val boundUserLazy = lazy { runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: if (allowsDefaultAccount) defaultAccountProvider.getDefaultUserBlocking() else null } + private val boundUser: User? by boundUserLazy /** * Returns [boundUser], or finishes the activity and returns null if the account doesn't exist. @@ -405,7 +406,10 @@ open class BaseActivity : AppCompatActivity() { } override fun startActivity(intent: Intent) { - val user = defaultAccountProvider.getDefaultUserBlocking() + // Links to the own server are opened for the account of this screen. Screens without an account (boundUser + // never loaded) use the default account, without resolving one from their intent. + val user = (if (boundUserLazy.isInitialized()) boundUser else null) + ?: defaultAccountProvider.getDefaultUserBlocking() if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) { val uri = intent.data.toString() if (user?.baseUrl != null && uri.startsWith(user.baseUrl!!)) { From 46a7ea8abaff3ddbfecc01ff98eec953493a2387 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 10:46:41 +0200 Subject: [PATCH 38/76] fix(status): bind the account dialog's status view models to the default account The status view models read the default account themselves when they were created and kept it for the lifetime of the hosting activity. When the default account changed while the conversation list stayed open (e.g. after a call for another account), the account dialog showed and changed the status of the old account. The dialog now reads the default account once and passes it to the view models via assisted injection. They are keyed by the account id, so a new pair is created when the default account changes. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ChooseAccountDialogCompose.kt | 27 ++++++++++++++++--- .../viewmodel/StatusMessageViewModel.kt | 16 +++++++---- .../viewmodel/StatusViewModel.kt | 18 ++++++++----- .../talk/dagger/modules/ViewModelModule.kt | 12 --------- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index fafb9c92b1d..e19a9d23bf7 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -84,6 +84,7 @@ import com.nextcloud.talk.chooseaccount.viewmodel.StatusUiState import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel @@ -123,6 +124,12 @@ class ChooseAccountDialogCompose { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var statusViewModelFactory: StatusViewModel.Factory + + @Inject + lateinit var statusMessageViewModelFactory: StatusMessageViewModel.Factory + @Inject lateinit var networkMonitor: NetworkMonitor @@ -134,10 +141,23 @@ class ChooseAccountDialogCompose { @Suppress("LongMethod") fun GetChooseAccountDialog(shouldDismiss: MutableState, activity: Activity, showEcosystem: Boolean) { if (shouldDismiss.value) return - // Scoped to the hosting activity, so they survive configuration changes and are cleared with it. + val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! + // Scoped to the hosting activity, so they survive configuration changes and are cleared with it. The status + // view models are bound to the default account, so they are keyed by it and replaced when the default account + // changes while the activity stays open. val invitationsViewModel: InvitationsViewModel = viewModel(factory = viewModelFactory) - val statusViewModel: StatusViewModel = viewModel(factory = viewModelFactory) - val statusMessageViewModel: StatusMessageViewModel = viewModel(factory = viewModelFactory) + val statusViewModel: StatusViewModel = viewModel( + key = "status-${currentUser.id}", + factory = ViewModelFactoryWithParams(StatusViewModel::class.java) { + statusViewModelFactory.build(currentUser) + } + ) + val statusMessageViewModel: StatusMessageViewModel = viewModel( + key = "statusMessage-${currentUser.id}", + factory = ViewModelFactoryWithParams(StatusMessageViewModel::class.java) { + statusMessageViewModelFactory.build(currentUser) + } + ) val colorScheme = viewThemeUtils.getColorScheme(activity) val status = remember { mutableStateOf(null) } val showOnlineStatusSheet = rememberSaveable { mutableStateOf(false) } @@ -146,7 +166,6 @@ class ChooseAccountDialogCompose { val statusViewState by statusViewModel.statusViewState.collectAsStateWithLifecycle() val invitationsStateByUser by invitationsViewModel.invitationsStateByUser.collectAsStateWithLifecycle() val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! val isStatusAvailable = CapabilitiesUtil.isUserStatusAvailable(currentUser) ecosystemManager = EcosystemManager(activity) diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt index 089eedcb903..5a4af662991 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt @@ -11,17 +11,19 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.chooseaccount.data.StatusRepository +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.status.ClearAtDto import com.nextcloud.talk.models.json.status.StatusDto import com.nextcloud.talk.models.json.status.predefined.PredefinedStatusDto -import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Calendar -import javax.inject.Inject private const val ONE_SECOND_IN_MILLIS = 1000L private const val ONE_MINUTE_IN_SECONDS = 60L @@ -34,12 +36,11 @@ private const val LAST_SECOND_OF_MINUTE = 59 private const val HTTP_STATUS_CODE_OK = 200 private const val HTTP_STATUS_CODE_NOT_FOUND = 404 -class StatusMessageViewModel @Inject constructor( +class StatusMessageViewModel @AssistedInject constructor( private val repository: StatusRepository, - private val defaultAccountProvider: DefaultAccountProvider + @Assisted private val currentUser: User ) : ViewModel() { - private val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! private val _emoji = MutableStateFlow("") @@ -219,6 +220,11 @@ class StatusMessageViewModel @Inject constructor( clearAt = statusMessageClearAtFromPosition(position) } + @AssistedFactory + interface Factory { + fun build(user: User): StatusMessageViewModel + } + companion object { private val TAG = StatusMessageViewModel::class.simpleName const val CLEAR_AT_POS_DONT_CLEAR = 0 diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt index c7a1f7c0a4e..6c5cf809553 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt @@ -10,23 +10,24 @@ package com.nextcloud.talk.chooseaccount.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.chooseaccount.data.StatusRepository +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.status.StatusOverall import com.nextcloud.talk.models.json.status.StatusType -import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.utils.ApiUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -class StatusViewModel @Inject constructor( +class StatusViewModel @AssistedInject constructor( private val repository: StatusRepository, - private val defaultAccountProvider: DefaultAccountProvider, - private val logger: Logger + private val logger: Logger, + @Assisted private val currentUser: User ) : ViewModel() { - private val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) private val _statusViewState = MutableStateFlow(StatusUiState.None) @@ -62,6 +63,11 @@ class StatusViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): StatusViewModel + } + companion object { private val TAG = StatusViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index d5298027e64..408033c151f 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -14,8 +14,6 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewViewModel -import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel -import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel @@ -152,16 +150,6 @@ abstract class ViewModelModule { @ViewModelKey(CallViewModel::class) abstract fun callViewModel(viewModel: CallViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(StatusViewModel::class) - abstract fun statusRepositoryViewModel(viewModel: StatusViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(StatusMessageViewModel::class) - abstract fun statusMessageViewModel(viewModel: StatusMessageViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(ChooseAccountShareToViewModel::class) From db078da53bdd9f0e1b5611aeb059b668f63ee57e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 10:54:38 +0200 Subject: [PATCH 39/76] fix(conversationlist): follow the default account when returning to the list The conversation list stayed bound to the account it was opened for. After joining a call for another account, which makes that account the default, and switching to picture in picture, the list of the previous account was shown while the account switcher already showed the called account as active. When the list returns from the background and the default account changed in the meantime, it now switches to that account. It does not switch while a message is forwarded or shared, which would lose it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../conversationlist/ConversationsListActivity.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index a0358f1a056..a7e18c142f7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -413,6 +413,18 @@ class ConversationsListActivity : BaseActivity() { return usesPush } + override fun onRestart() { + super.onRestart() + // The list shows the default account. If another account became the default while the list was in the + // background (e.g. by joining a call for it), switch to that account. Not while forwarding or sharing, which + // would lose the pending message. + val defaultUserId = defaultAccountProvider.getDefaultUserBlocking()?.id ?: return + val isPickingConversation = forwardMessage || hasActivityActionSendIntent() + if (defaultUserId != currentUser?.id && !isPickingConversation) { + startActivity(createAccountSwitchIntent(this, defaultUserId)) + } + } + override fun onResume() { super.onResume() From 08e20153ee2c6f7abe523502b7fc0dd8a6744151 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 11:31:33 +0200 Subject: [PATCH 40/76] fix(conversationlist): don't relaunch a received intent for another account When an intent for another account reached the conversation list via onNewIntent, the list relaunched the received intent unchanged. The activity is exported, so that intent can come from another app, including flags like URI permission grants (lint UnsafeIntentLaunch). The list now starts a new explicit intent for itself that only takes over the action, data and extras of the received one. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../conversationlist/ConversationsListActivity.kt | 12 +++++++++--- .../viewmodels/ConversationsListViewModel.kt | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index a7e18c142f7..bfe869cc0b3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -245,10 +245,16 @@ class ConversationsListActivity : BaseActivity() { super.onNewIntent(intent) val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) if (newUserId != 0L && newUserId != currentUser?.id) { - // The list is bound to one account, so open a fresh instance for the other one. - intent.removeFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + // The list is bound to one account, so open a fresh instance for the other one. The activity is + // exported, so the received intent is not launched again as it is: the new one only targets this + // activity and takes over no flags like URI permission grants. + val accountIntent = Intent(this, ConversationsListActivity::class.java).apply { + action = intent.action + data = intent.data + intent.extras?.let { putExtras(it) } + } finish() - startActivity(intent) + startActivity(accountIntent) return } currentUser?.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index fab1a11962e..5fab5406286 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -69,7 +69,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions", "Detekt.LongMethod") class ConversationsListViewModel @AssistedInject constructor( private val repository: OfflineConversationsRepository, private val threadsRepository: ThreadsRepository, From 50c8b812710fb411888cb7bafa1e22fd2efdb2af Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 12:46:50 +0200 Subject: [PATCH 41/76] fix(user): don't reset the default account by saving old user copies Background jobs and settings read a user, waited for the server and then wrote back the whole row, including the "current" flag and the token as they were when the user was read. If the default account changed in the meantime (e.g. by a notification tap while the capabilities sync ran), the change was undone or two accounts ended up marked as active, which hid accounts in the account switcher. Reading the active user no longer repairs this, so it lasted until the next app start. Capabilities, signaling settings, display name, client certificate and credentials are now stored with updates that only write their own columns. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../account/data/io/LocalLoginDataSource.kt | 10 ++- .../com/nextcloud/talk/data/user/UsersDao.kt | 21 +++++ .../talk/data/user/UsersRepository.kt | 8 ++ .../talk/data/user/UsersRepositoryImpl.kt | 23 +++++ .../talk/jobs/CapabilitiesFetcher.kt | 2 +- .../talk/jobs/SignalingSettingsWorker.java | 5 +- .../talk/settings/SettingsActivity.kt | 6 +- .../com/nextcloud/talk/users/UserManager.kt | 23 +++-- .../utils/preview/ComposePreviewUtilsDaos.kt | 30 +++++++ .../data/user/UsersDaoPartialUpdateTest.kt | 85 +++++++++++++++++++ .../talk/jobs/CapabilitiesFetcherTest.kt | 15 ++-- .../nextcloud/talk/users/UserManagerTest.kt | 11 ++- 12 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt index 0534037cc94..826bf1c84d2 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt @@ -26,9 +26,13 @@ class LocalLoginDataSource(val userManager: UserManager, val appPreferences: App fun updateUser(loginData: LoginCompletion) { val currentUser = runBlocking { userManager.getDefaultUser() } if (currentUser != null) { - currentUser.clientCertificate = appPreferences.temporaryClientCertAlias - currentUser.token = loginData.appPassword - runBlocking { userManager.updateOrCreateUser(currentUser) } + runBlocking { + userManager.updateCredentials( + currentUser.id!!, + loginData.appPassword, + appPreferences.temporaryClientCertAlias + ) + } } } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt index 465dd3fddbb..0416e33e8f0 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt @@ -14,6 +14,9 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow @@ -84,6 +87,24 @@ interface UsersDao { @Query("Update User SET pushConfigurationState = :state WHERE id == :id") suspend fun updatePushState(id: Long, state: PushConfigurationState): Int + // The following updates only write the given columns. Writing back a whole user row that was read earlier + // (e.g. before a network request) would reset columns that changed in the meantime, like "current". + + @Query("UPDATE User SET capabilities = :capabilities, serverVersion = :serverVersion WHERE id == :id") + suspend fun updateCapabilities(id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto?): Int + + @Query("UPDATE User SET externalSignalingServer = :externalSignalingServer WHERE id == :id") + suspend fun updateExternalSignalingServer(id: Long, externalSignalingServer: ExternalSignalingServer?): Int + + @Query("UPDATE User SET displayName = :displayName WHERE id == :id") + suspend fun updateDisplayName(id: Long, displayName: String?): Int + + @Query("UPDATE User SET clientCertificate = :clientCertificate WHERE id == :id") + suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int + + @Query("UPDATE User SET token = :token, clientCertificate = :clientCertificate WHERE id == :id") + suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int + companion object { const val TAG = "UsersDao" } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt index 5e5f62d9b8a..746f58461be 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt @@ -8,6 +8,9 @@ package com.nextcloud.talk.data.user import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow @@ -29,4 +32,9 @@ interface UsersRepository { suspend fun setUserAsActiveWithId(id: Long): Boolean suspend fun deleteUser(user: User): Int suspend fun updatePushState(id: Long, state: PushConfigurationState): Int + suspend fun updateCapabilities(id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto?): Int + suspend fun updateExternalSignalingServer(id: Long, externalSignalingServer: ExternalSignalingServer?): Int + suspend fun updateDisplayName(id: Long, displayName: String?): Int + suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int + suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt index 9418e1a1fb0..2f5239364ed 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt @@ -9,6 +9,9 @@ package com.nextcloud.talk.data.user import android.util.Log import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -64,6 +67,26 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { override suspend fun updatePushState(id: Long, state: PushConfigurationState): Int = usersDao.updatePushState(id, state) + override suspend fun updateCapabilities( + id: Long, + capabilities: CapabilitiesDto?, + serverVersion: ServerVersionDto? + ): Int = usersDao.updateCapabilities(id, capabilities, serverVersion) + + override suspend fun updateExternalSignalingServer( + id: Long, + externalSignalingServer: ExternalSignalingServer? + ): Int = usersDao.updateExternalSignalingServer(id, externalSignalingServer) + + override suspend fun updateDisplayName(id: Long, displayName: String?): Int = + usersDao.updateDisplayName(id, displayName) + + override suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = + usersDao.updateClientCertificate(id, clientCertificate) + + override suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = + usersDao.updateCredentials(id, token, clientCertificate) + companion object { private val TAG = UsersRepositoryImpl::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt index 85886ec93d9..57c1b33400c 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt @@ -84,7 +84,7 @@ class CapabilitiesFetcher( user.serverVersion = capabilitiesOverall.ocs?.data?.serverVersion return try { - val success = userManager.updateOrCreateUser(user) > 0 + val success = userManager.updateCapabilities(user.id!!, user.capabilities, user.serverVersion) > 0 if (!success) { Log.w(TAG, "Error updating user") } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java index 1f11f65b3d6..fecb112a722 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java @@ -116,7 +116,10 @@ public void onNext(SignalingSettingsOverall signalingSettingsOverall) { try { int rows = BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, - (scope, continuation) -> userManager.saveUser(user, continuation)); + (scope, continuation) -> userManager.updateExternalSignalingServer( + user.getId(), + externalSignalingServer, + continuation)); saved = rows > 0; } catch (Exception e) { saved = false; diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 22e7809fb29..60cd1652dbd 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -778,8 +778,9 @@ class SettingsActivity : } Log.d(TAG, "host: $host and port: $port") currentUser!!.clientCertificate = finalAlias + val userId = currentUser!!.id!! lifecycleScope.launch { - userManager.updateOrCreateUser(currentUser!!) + userManager.updateClientCertificate(userId, finalAlias) } }, arrayOf("RSA", "EC"), @@ -1096,8 +1097,9 @@ class SettingsActivity : } if ((!TextUtils.isEmpty(displayName) && !(displayName == currentUser!!.displayName))) { currentUser!!.displayName = displayName + val userId = currentUser!!.id!! lifecycleScope.launch { - userManager.updateOrCreateUser(currentUser!!) + userManager.updateDisplayName(userId, displayName) } binding.nameText.text = currentUser!!.displayName } diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index 280a5249b33..efabb996677 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -124,20 +124,33 @@ class UserManager internal constructor(private val userRepository: UsersReposito } } + // The following updates only write the given fields, so they cannot reset fields that changed since the user + // was read, like the default account flag. Use them instead of saving a whole user that was read earlier. + suspend fun updateExternalSignalingServer(id: Long, externalSignalingServer: ExternalSignalingServer): Int { - val user = userRepository.getUserWithId(id) ?: throw NoSuchElementException() - user.externalSignalingServer = externalSignalingServer - return userRepository.updateUser(user) + val updated = userRepository.updateExternalSignalingServer(id, externalSignalingServer) + if (updated == 0) throw NoSuchElementException() + return updated } + suspend fun updateCapabilities(id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto?): Int = + userRepository.updateCapabilities(id, capabilities, serverVersion) + + suspend fun updateDisplayName(id: Long, displayName: String?): Int = + userRepository.updateDisplayName(id, displayName) + + suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = + userRepository.updateClientCertificate(id, clientCertificate) + + suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = + userRepository.updateCredentials(id, token, clientCertificate) + suspend fun updateOrCreateUser(user: User): Int = when (user.id) { null -> userRepository.insertUser(user).toInt() else -> userRepository.updateUser(user) } - suspend fun saveUser(user: User): Int = userRepository.updateUser(user) - suspend fun setUserAsActive(user: User): Boolean { Log.d(TAG, "setUserAsActive:" + user.id!!) val success = userRepository.setUserAsActiveWithId(user.id!!) diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 27ed5511b49..8f85ccbcc25 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -15,6 +15,9 @@ import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.database.model.ConversationEntity import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -241,6 +244,33 @@ class DummyUserDaoImpl : UsersDao { 0 } } + + override suspend fun updateCapabilities( + id: Long, + capabilities: CapabilitiesDto?, + serverVersion: ServerVersionDto? + ): Int = updateDummyUser(id) { it.copy(capabilities = capabilities, serverVersion = serverVersion) } + + override suspend fun updateExternalSignalingServer( + id: Long, + externalSignalingServer: ExternalSignalingServer? + ): Int = updateDummyUser(id) { it.copy(externalSignalingServer = externalSignalingServer) } + + override suspend fun updateDisplayName(id: Long, displayName: String?): Int = + updateDummyUser(id) { it.copy(displayName = displayName) } + + override suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = + updateDummyUser(id) { it.copy(clientCertificate = clientCertificate) } + + override suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = + updateDummyUser(id) { it.copy(token = token, clientCertificate = clientCertificate) } + + private fun updateDummyUser(id: Long, update: (UserEntity) -> UserEntity): Int { + val index = dummyUsers.indexOfFirst { it.id == id } + if (index == -1) return 0 + dummyUsers[index] = update(dummyUsers[index]) + return 1 + } } class DummyConversationDaoImpl : ConversationsDao { diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt new file mode 100644 index 00000000000..0a32226fb39 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user + +import android.app.Application +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.talk.data.source.local.TalkDatabase +import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The partial updates must only write their own columns, so a user that was read before the default account + * changed cannot reset it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UsersDaoPartialUpdateTest { + + private lateinit var db: TalkDatabase + private lateinit var dao: UsersDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, TalkDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.usersDao() + runBlocking { + dao.saveUser(UserEntity(id = USER_A, username = "a", baseUrl = BASE_URL, token = "tokenA", current = true)) + dao.saveUser(UserEntity(id = USER_B, username = "b", baseUrl = BASE_URL, token = "tokenB")) + } + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `partial updates of a previously active user keep the new default account`() = + runBlocking { + dao.setUserAsActiveWithId(USER_B) + + dao.updateCapabilities(USER_A, CapabilitiesDto(), ServerVersionDto(major = 30)) + dao.updateExternalSignalingServer(USER_A, ExternalSignalingServer(externalSignalingServer = "wss://s")) + dao.updateDisplayName(USER_A, "Alice") + dao.updateClientCertificate(USER_A, "alias") + dao.updateCredentials(USER_A, "newToken", "alias") + + val userA = dao.getUserWithId(USER_A)!! + assertFalse(userA.current) + assertTrue(dao.getUserWithId(USER_B)!!.current) + assertEquals(USER_B, dao.getActiveUser()!!.id) + assertEquals(30, userA.serverVersion?.major) + assertEquals("wss://s", userA.externalSignalingServer?.externalSignalingServer) + assertEquals("Alice", userA.displayName) + assertEquals("newToken", userA.token) + assertEquals("alias", userA.clientCertificate) + } + + companion object { + private const val USER_A = 1L + private const val USER_B = 2L + private const val BASE_URL = "https://example.com" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt index 5b1fd29782b..5610b0d4cc6 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt @@ -22,6 +22,8 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -51,7 +53,7 @@ class CapabilitiesFetcherTest { val testUser = user() val capabilities = CapabilitiesDto() val serverVersion = ServerVersionDto(major = 30) - wheneverBlocking { userManager.updateOrCreateUser(testUser) }.thenReturn(1) + wheneverBlocking { userManager.updateCapabilities(USER_ID, capabilities, serverVersion) }.thenReturn(1) val result = fetcher.updateUser(overall(capabilities, serverVersion), testUser) @@ -65,7 +67,7 @@ class CapabilitiesFetcherTest { fun `returns false and posts failure when no row was updated`() = runTest { val testUser = user() - wheneverBlocking { userManager.updateOrCreateUser(testUser) }.thenReturn(0) + wheneverBlocking { userManager.updateCapabilities(any(), anyOrNull(), anyOrNull()) }.thenReturn(0) val result = fetcher.updateUser(overall(CapabilitiesDto()), testUser) @@ -77,7 +79,8 @@ class CapabilitiesFetcherTest { fun `returns false and posts failure when persisting the user throws`() = runTest { val testUser = user() - wheneverBlocking { userManager.updateOrCreateUser(testUser) }.thenThrow(RuntimeException("db error")) + wheneverBlocking { userManager.updateCapabilities(any(), anyOrNull(), anyOrNull()) } + .thenThrow(RuntimeException("db error")) val result = fetcher.updateUser(overall(CapabilitiesDto()), testUser) @@ -94,7 +97,7 @@ class CapabilitiesFetcherTest { assertFalse(result) verify(eventBus).post(EventStatus(USER_ID, EventStatus.EventType.CAPABILITIES_FETCH, false)) - verifyBlocking(userManager, never()) { updateOrCreateUser(testUser) } + verifyBlocking(userManager, never()) { updateCapabilities(any(), anyOrNull(), anyOrNull()) } } @Test @@ -106,7 +109,7 @@ class CapabilitiesFetcherTest { assertFalse(result) verify(eventBus).post(EventStatus(USER_ID, EventStatus.EventType.CAPABILITIES_FETCH, false)) - verifyBlocking(userManager, never()) { updateOrCreateUser(testUser) } + verifyBlocking(userManager, never()) { updateCapabilities(any(), anyOrNull(), anyOrNull()) } } @Test @@ -118,7 +121,7 @@ class CapabilitiesFetcherTest { assertFalse(result) verify(eventBus).post(EventStatus(USER_ID, EventStatus.EventType.CAPABILITIES_FETCH, false)) - verifyBlocking(userManager, never()) { updateOrCreateUser(testUser) } + verifyBlocking(userManager, never()) { updateCapabilities(any(), anyOrNull(), anyOrNull()) } } companion object { diff --git a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt index e283d45bd3b..c381438010c 100644 --- a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt @@ -231,10 +231,11 @@ class UserManagerTest { @Test fun `updateExternalSignalingServer throws when the user does not exist`() = runTest { - wheneverBlocking { usersRepository.getUserWithId(7L) }.thenReturn(null) + val server = ExternalSignalingServer() + wheneverBlocking { usersRepository.updateExternalSignalingServer(7L, server) }.thenReturn(0) try { - userManager.updateExternalSignalingServer(7L, ExternalSignalingServer()) + userManager.updateExternalSignalingServer(7L, server) fail("Expected NoSuchElementException") } catch (expected: NoSuchElementException) { // expected @@ -244,15 +245,13 @@ class UserManagerTest { @Test fun `updateExternalSignalingServer updates the matching user`() = runTest { - val existing = user(id = 7, username = "userA", baseUrl = "https://example.com") val server = ExternalSignalingServer(externalSignalingServer = "https://signaling.example.com") - wheneverBlocking { usersRepository.getUserWithId(7L) }.thenReturn(existing) - wheneverBlocking { usersRepository.updateUser(existing) }.thenReturn(1) + wheneverBlocking { usersRepository.updateExternalSignalingServer(7L, server) }.thenReturn(1) val result = userManager.updateExternalSignalingServer(7L, server) assertEquals(1, result) - assertEquals(server, existing.externalSignalingServer) + verify(usersRepository, never()).updateUser(any()) } @Test From a6a03b24cb17d1d0d5ef8a9ed0bebd2c3eac5a8b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:12:55 +0200 Subject: [PATCH 42/76] fix(share): keep the chosen account when switching it in the share-to chooser After switching the account, the share-to chooser recreated the conversation list. The recreated list kept its intent with the previous account's id and its view models, so it showed the previous account again and made it the default again. Shared content went to the previous account. The chooser now replaces the list by a new one for the chosen account, which gets the shared content and the read access to shared files. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 20 +++++++++++++++++++ .../ChooseAccountShareToDialogFragment.kt | 3 ++- .../ChooseAccountShareToViewModel.kt | 2 +- .../model/ChooseAccountShareToViewState.kt | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index bfe869cc0b3..8c63e65a57b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -699,6 +699,26 @@ class ConversationsListActivity : BaseActivity() { showAccountDialogState.value = true } + /** + * Continues the pending share in the conversation list of the account with the internal id [userId]. + * + * The list is bound to its account, so it is replaced by a new one for that account instead of being recreated, + * which would keep the account and its view models. The new list gets the shared content and the read access to + * the shared files, which ends with this activity. + */ + fun continueShareWithAccount(userId: Long) { + val shareIntent = Intent(this, ConversationsListActivity::class.java).apply { + action = intent.action + setDataAndType(intent.data, intent.type) + intent.extras?.let { putExtras(it) } + clipData = intent.clipData + putExtra(KEY_INTERNAL_USER_ID, userId) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(shareIntent) + finish() + } + private fun hasActivityActionSendIntent(): Boolean = Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt index f2a36063e12..1f8074760a1 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserSuccessStateChooseAccountShareTo @@ -75,7 +76,7 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { } is SwitchUserSuccessStateChooseAccountShareTo -> { cookieManager.cookieStore.removeAll() - activity?.recreate() + (activity as? ConversationsListActivity)?.continueShareWithAccount(state.user.id!!) dismiss() } else -> {} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt index 01660597f95..2c20d7a7266 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt @@ -58,7 +58,7 @@ class ChooseAccountShareToViewModel @Inject constructor( val success = userManager.setUserAsActive(user) _chooseAccountShareToViewState.value = if (success) { - SwitchUserSuccessStateChooseAccountShareTo + SwitchUserSuccessStateChooseAccountShareTo(user) } else { SwitchUserErrorStateChooseAccountShareTo } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt index ec79369ab2b..c33043c9108 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt @@ -13,5 +13,5 @@ sealed interface ChooseAccountShareToViewState object LoadUsersStartStateChooseAccountShareTo : ChooseAccountShareToViewState open class LoadUsersSuccessStateChooseAccountShareTo(val users: List) : ChooseAccountShareToViewState -object SwitchUserSuccessStateChooseAccountShareTo : ChooseAccountShareToViewState +class SwitchUserSuccessStateChooseAccountShareTo(val user: User) : ChooseAccountShareToViewState object SwitchUserErrorStateChooseAccountShareTo : ChooseAccountShareToViewState From 2eca1b4ad388f9233a5649d1a73348125d9a4a4d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:20:50 +0200 Subject: [PATCH 43/76] fix(call): reset the dialing state when CallActivity closes before setup Chat and conversation list mark the app as dialing before starting a call, which hangup() resets. Since onDestroy() only cleans up calls that were set up, a call screen closing early (missing account or data, unsupported call encryption) left the dialing state set, so chats no longer left their room when paused. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/activities/CallActivity.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 3a443ee742c..a295911ac0a 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -1596,6 +1596,10 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "onDestroy: starting CallActivity for pending call intent") startActivity(it) } + } else { + // The screen that started the call marked it as dialing, which hangup() would reset. Without resetting + // it here, chats would keep treating the app as being in a call. + ApplicationWideCurrentRoomHolder.getInstance().isDialing = false } super.onDestroy() From 39d6e822c09b69c7e8352669175203cf1141e7e5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:31:35 +0200 Subject: [PATCH 44/76] fix(conversationinfo): don't crash when the ban list is recreated DialogBanListFragment only had a constructor with parameters, so the fragment manager could not recreate it after a configuration change or process death and the app crashed with an InstantiationException, e.g. when rotating the device while the ban list was open. The room token and the account are now passed as fragment arguments. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationInfoActivity.kt | 2 +- .../talk/ui/dialog/DialogBanListFragment.kt | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index befdeae9421..12b8ea0a0e8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -505,7 +505,7 @@ class ConversationInfoActivity : BaseActivity() { private fun listBans() { val transaction = supportFragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - transaction.add(android.R.id.content, DialogBanListFragment(conversationToken, conversationUser)) + transaction.add(android.R.id.content, DialogBanListFragment.newInstance(conversationToken, conversationUser)) .addToBackStack(null) .commit() } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt index 7510aa4e079..ab7ea2f1531 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt @@ -12,6 +12,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter +import androidx.core.os.BundleCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment @@ -30,7 +31,13 @@ import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DialogBanListFragment(val roomToken: String, private val conversationUser: User) : DialogFragment() { +class DialogBanListFragment : DialogFragment() { + + // Read from the arguments, so the fragment can be recreated by the system after rotation or process death. + private val roomToken: String by lazy { requireArguments().getString(ROOM_TOKEN_ARG)!! } + private val conversationUser: User by lazy { + BundleCompat.getParcelable(requireArguments(), USER_ARG, User::class.java)!! + } lateinit var binding: FragmentDialogBanListBinding @@ -140,8 +147,17 @@ class DialogBanListFragment(val roomToken: String, private val conversationUser: } companion object { + private const val ROOM_TOKEN_ARG = "ROOM_TOKEN_ARG" + private const val USER_ARG = "USER_ARG" + @JvmStatic - fun newInstance(roomToken: String, user: User) = DialogBanListFragment(roomToken, user) + fun newInstance(roomToken: String, user: User) = + DialogBanListFragment().apply { + arguments = Bundle().apply { + putString(ROOM_TOKEN_ARG, roomToken) + putParcelable(USER_ARG, user) + } + } const val ONE_SEC = 1000L } } From a1387985dd7a04041bc2decbe0138aaff54fc000 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:46:39 +0200 Subject: [PATCH 45/76] fix(upload): fail cleanly when the account of an upload doesn't exist When the account of an upload no longer existed, e.g. because it was removed while the upload waited for a retry, the worker threw before the file name, the notification manager and the placeholder ids were set. The failure handling then threw again, so neither the failure notification was shown nor the placeholder message marked as failed. The placeholder ids are now read first, a missing account fails the upload directly, and the failure notification no longer depends on the upload having started. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/jobs/UploadAndShareFilesWorker.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index f255a941b4e..985bfdaa6b6 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -99,7 +99,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa lateinit var fileName: String - private var mNotifyManager: NotificationManager? = null + private val notificationManager by lazy { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } lateinit var roomToken: String lateinit var conversationName: String @@ -139,17 +141,21 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) private fun doUpload(): Result { return try { - currentUser = checkNotNull( - runBlocking { userManager.getUserWithId(inputData.getLong(KEY_INTERNAL_USER_ID, 0L)) } - ) + // Read first, so a failure below can still mark the placeholder message as failed. + referenceId = inputData.getString(KEY_REFERENCE_ID) + internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID) + + val userId = inputData.getLong(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(userId) } ?: run { + // E.g. the account was removed while the upload waited for a retry. + Log.e(TAG, "No user found for id $userId") + return failUpload() + } val sourceFile = inputData.getString(DEVICE_SOURCE_FILE) roomToken = inputData.getString(ROOM_TOKEN)!! conversationName = inputData.getString(CONVERSATION_NAME)!! val metaData = inputData.getString(META_DATA) - referenceId = inputData.getString(KEY_REFERENCE_ID) - internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID) - checkNotNull(currentUser) checkNotNull(sourceFile) require(sourceFile.isNotEmpty()) checkNotNull(roomToken) @@ -158,8 +164,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa fileName = FileUtils.getFileName(sourceFileUri, context) file = FileUtils.getFileFromUri(context, sourceFileUri) - initNotificationSetup() - if (inputData.getBoolean(COMPRESS_IMAGES, false)) { sourceFileUri = compressMediaIfPossible(sourceFileUri) } @@ -429,15 +433,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa super.onStopped() } - private fun initNotificationSetup() { - mNotifyManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - } - private fun showFailedToUploadNotification() { val failureTitle = getResourceString(context, R.string.nc_upload_failed_notification_title) val failureText = String.format( getResourceString(context, R.string.nc_upload_failed_notification_text), - fileName + failedFileName() ) val failureNotification = NotificationCompat.Builder( context, @@ -451,9 +451,17 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .setOngoing(false) .build() - mNotifyManager!!.notify(SystemClock.uptimeMillis().toInt(), failureNotification) + notificationManager.notify(SystemClock.uptimeMillis().toInt(), failureNotification) } + // The upload can fail before the file name is known, e.g. when its account does not exist anymore. + private fun failedFileName(): String = + if (::fileName.isInitialized) { + fileName + } else { + inputData.getString(DEVICE_SOURCE_FILE)?.toUri()?.lastPathSegment.orEmpty() + } + private fun getResourceString(context: Context, resourceId: Int): String = context.resources.getString(resourceId) companion object { From c67d1cba0faad0300b1659538de6774378fc6a56 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:52:35 +0200 Subject: [PATCH 46/76] fix(user): repair multiple active users in a single statement The startup repair counted the active users, read the active one and then set it as the only active user in separate statements. A switch of the default account in between (e.g. by a notification tap during app start) was undone. The repair is now a single UPDATE that keeps only the active user with the highest id, which is the one getActiveUser() returns. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/data/user/UsersDao.kt | 10 ++- .../talk/data/user/UsersRepository.kt | 2 +- .../talk/data/user/UsersRepositoryImpl.kt | 2 +- .../com/nextcloud/talk/users/UserManager.kt | 6 +- .../utils/preview/ComposePreviewUtilsDaos.kt | 2 +- .../talk/data/user/UsersDaoRepairTest.kt | 79 +++++++++++++++++++ .../nextcloud/talk/users/UserManagerTest.kt | 17 +--- 7 files changed, 95 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt index 0416e33e8f0..5d03dfed2d4 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt @@ -58,8 +58,14 @@ interface UsersDao { @Query("SELECT * FROM User where id = :id") fun getUserWithIdFlow(id: Long): Flow - @Query("SELECT COUNT(*) FROM User where current = 1") - suspend fun getActiveUsersCount(): Int + // If several users are marked as active, keeps only the one with the highest id, matching getActiveUser(). + // A single statement, so it cannot interleave with setUserAsActiveWithId(). SQLite evaluates the uncorrelated + // subqueries once, before any row is changed. + @Query( + "UPDATE User SET current = CASE WHEN id = (SELECT MAX(id) FROM User WHERE current = 1) THEN 1 ELSE 0 END " + + "WHERE (SELECT COUNT(*) FROM User WHERE current = 1) > 1" + ) + suspend fun repairMultipleActiveUsers(): Int @Query("SELECT * FROM User where id = :id AND scheduledForDeletion != 1") suspend fun getUserWithIdNotScheduledForDeletion(id: Long): UserEntity? diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt index 746f58461be..bf79cf82a96 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt @@ -21,7 +21,7 @@ interface UsersRepository { suspend fun getUsers(): List suspend fun getUserWithId(id: Long): User? fun getUserWithIdFlow(id: Long): Flow - suspend fun getActiveUsersCount(): Int + suspend fun repairMultipleActiveUsers(): Int suspend fun getUserWithIdNotScheduledForDeletion(id: Long): User? suspend fun getUserWithUserId(userId: String): User? suspend fun getUsersScheduledForDeletion(): List diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt index 2f5239364ed..8ce02c2a666 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt @@ -35,7 +35,7 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { UserMapper.toModel(it) } - override suspend fun getActiveUsersCount(): Int = usersDao.getActiveUsersCount() + override suspend fun repairMultipleActiveUsers(): Int = usersDao.repairMultipleActiveUsers() override suspend fun getUserWithIdNotScheduledForDeletion(id: Long): User? = UserMapper.toModel(usersDao.getUserWithIdNotScheduledForDeletion(id)) diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index efabb996677..604de9ea9c8 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -53,10 +53,8 @@ class UserManager internal constructor(private val userRepository: UsersReposito * matching the user returned by [getDefaultUser]. */ suspend fun repairMultipleActiveUsers() { - if (userRepository.getActiveUsersCount() > 1) { - val activeUser = userRepository.getActiveUser() ?: return - Log.w(TAG, "Multiple active users found, keeping ${activeUser.id} as active") - userRepository.setUserAsActiveWithId(activeUser.id!!) + if (userRepository.repairMultipleActiveUsers() > 0) { + Log.w(TAG, "Multiple active users found, kept the one with the highest id as active") } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 8f85ccbcc25..99220629339 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -210,7 +210,7 @@ class DummyUserDaoImpl : UsersDao { override fun getUserWithIdFlow(id: Long): Flow = flowOf(dummyUsers.find { it.id == id }) - override suspend fun getActiveUsersCount(): Int = if (activeUserId != null) 1 else 0 + override suspend fun repairMultipleActiveUsers(): Int = 0 override suspend fun getUserWithIdNotScheduledForDeletion(id: Long): UserEntity? = dummyUsers.find { it.id == id && !it.scheduledForDeletion } diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt new file mode 100644 index 00000000000..8a419140c15 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user + +import android.app.Application +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.talk.data.source.local.TalkDatabase +import com.nextcloud.talk.data.user.model.UserEntity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UsersDaoRepairTest { + + private lateinit var db: TalkDatabase + private lateinit var dao: UsersDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, TalkDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.usersDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `several active users are reduced to the one with the highest id`() = + runBlocking { + saveUsers(activeIds = setOf(1L, 3L)) + + val updated = dao.repairMultipleActiveUsers() + + assertEquals(USER_IDS.size, updated) + assertEquals(listOf(3L), activeIds()) + } + + @Test + fun `a single active user is left unchanged`() = + runBlocking { + saveUsers(activeIds = setOf(1L)) + + val updated = dao.repairMultipleActiveUsers() + + assertEquals(0, updated) + assertEquals(listOf(1L), activeIds()) + } + + private suspend fun saveUsers(activeIds: Set) { + USER_IDS.forEach { id -> + dao.saveUser(UserEntity(id = id, username = "user$id", baseUrl = BASE_URL, current = id in activeIds)) + } + } + + private suspend fun activeIds(): List = dao.getUsers().filter { it.current }.map { it.id } + + companion object { + private val USER_IDS = listOf(1L, 2L, 3L) + private const val BASE_URL = "https://example.com" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt index c381438010c..c67a447b798 100644 --- a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt @@ -82,24 +82,13 @@ class UserManagerTest { } @Test - fun `repairMultipleActiveUsers keeps only the resolved active user when several are active`() = + fun `repairMultipleActiveUsers repairs in a single repository call`() = runTest { - val active = user(id = 2, username = "userB", baseUrl = "https://example.com", current = true) - wheneverBlocking { usersRepository.getActiveUsersCount() }.thenReturn(2) - wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) - - userManager.repairMultipleActiveUsers() - - verifyBlocking(usersRepository) { setUserAsActiveWithId(2L) } - } - - @Test - fun `repairMultipleActiveUsers does not write when at most one user is active`() = - runTest { - wheneverBlocking { usersRepository.getActiveUsersCount() }.thenReturn(1) + wheneverBlocking { usersRepository.repairMultipleActiveUsers() }.thenReturn(2) userManager.repairMultipleActiveUsers() + verifyBlocking(usersRepository) { repairMultipleActiveUsers() } verifyBlocking(usersRepository, never()) { setUserAsActiveWithId(any()) } } From 63d37098b1b3fb908131a70ad6c7dba5c5748624 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:56:17 +0200 Subject: [PATCH 47/76] fix(location): theme the address search with the account of the chat The location picker started the address search without the account, so it used the colors of the default account instead of the chat's account. The account is now passed on and the address search is bound to it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/location/GeocodingActivity.kt | 1 + .../com/nextcloud/talk/location/LocationPickerActivity.kt | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt index b8a1e019f2d..acea84e0fff 100644 --- a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt @@ -41,6 +41,7 @@ class GeocodingActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + setUpBoundUserOrFinish() ?: return roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(BundleKeys.KEY_CHAT_API_VERSION, 1) diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index dfb20b518a5..bda0ae596e8 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -18,11 +18,13 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.getParcelableExtraProvider import com.nextcloud.talk.location.components.LocationPickerScreen import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_GEOCODING_RESULT +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import javax.inject.Inject @@ -43,6 +45,7 @@ class LocationPickerActivity : BaseActivity() { lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var viewModel: LocationPickerViewModel + private lateinit var user: User private lateinit var roomToken: String private var chatApiVersion: Int = 1 @@ -68,7 +71,7 @@ class LocationPickerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return + user = setUpBoundUserOrFinish() ?: return roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) @@ -126,6 +129,7 @@ class LocationPickerActivity : BaseActivity() { private fun navigateToGeocoding() { val intent = Intent(this, GeocodingActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, user.id) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra(KEY_CHAT_API_VERSION, chatApiVersion) geocodingLauncher.launch(intent) From 164efd169297f784594c0432620a319ec25c5f19 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:58:06 +0200 Subject: [PATCH 48/76] remove unused val Signed-off-by: Marcel Hibbe --- .../talk/shareditems/activities/SharedItemsActivity.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index a507bac38f9..ac080d75ccd 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -46,8 +46,6 @@ class SharedItemsActivity : BaseActivity() { @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory - private val conversationUserId: Long by lazy { resolveUserIdFromIntent() } - private lateinit var conversationUser: User val roomToken: String by lazy { From a1f03ff35ab5e5f50d4fc1cf1e925d403ee5ad18 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 13:59:40 +0200 Subject: [PATCH 49/76] docs(user): describe when the default account fallback is kept The id written to the intent keeps an activity on its account across configuration changes, but not after process death, where the system restores the original intent. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/BaseActivity.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index b8d07425820..2f7dced6ced 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -111,9 +111,11 @@ open class BaseActivity : AppCompatActivity() { /** * Internal id of the account this activity was started for, taken from [BundleKeys.KEY_INTERNAL_USER_ID]. - * Without it, the default account is used and its id is written to the intent, so a recreated activity stays - * on the same account. That is only intended for activities with [allowsDefaultAccount]: for all others it - * fails in debug builds and is logged as an error in release builds. Returns 0 if there is no account at all. + * Without it, the default account is used and its id is written to the intent, so an activity recreated after a + * configuration change stays on the same account. After process death the system restores the original intent, + * so the activity uses the default account at that time. That is only intended for activities with + * [allowsDefaultAccount]: for all others it fails in debug builds and is logged as an error in release builds. + * Returns 0 if there is no account at all. */ protected fun resolveUserIdFromIntent(): Long { val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) From 2d7726519574c5263dbc1282616e4c3ebf2e6bad Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 14:05:46 +0200 Subject: [PATCH 50/76] fix(conversationinfoedit): create the avatar picker right away The avatar picker was only created once the conversation had loaded from the server. When the activity was recreated after process death, e.g. while the camera was open, the camera or image picker result arrived before the conversation loaded and was silently dropped. The picker is now created with the bound account in onCreate(). Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationInfoEditActivity.kt | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index f0d1780576e..52a24a0cba9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.res.stringResource import androidx.core.net.toFile import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.github.dhaval2404.imagepicker.ImagePicker import com.nextcloud.talk.activities.BaseActivity @@ -33,9 +32,6 @@ import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditVie import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.launch @AutoInjector(NextcloudTalkApplication::class) class ConversationInfoEditActivity : BaseActivity() { @@ -76,6 +72,9 @@ class ConversationInfoEditActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) val user = setUpBoundUserOrFinish() ?: return + // Created right away, so results of the camera or an image picker are handled even when the activity was + // recreated after process death and the conversation is still loading. + pickImage = PickImage(this, user) val roomToken = intent.extras?.getString(BundleKeys.KEY_ROOM_TOKEN)!! @@ -84,19 +83,6 @@ class ConversationInfoEditActivity : BaseActivity() { conversationInfoEditViewModel.initialize(user, roomToken) - conversationInfoEditViewModel.uiState.value.conversationUser?.let { user -> - pickImage = PickImage(this, user) - } - - if (pickImage == null) { - lifecycleScope.launch { - val user = conversationInfoEditViewModel.uiState - .mapNotNull { it.conversationUser } - .first() - pickImage = PickImage(this@ConversationInfoEditActivity, user) - } - } - setupCompose() } From 8924426330966f432a5787308c3dbe0a2c91576d Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 14:16:01 +0200 Subject: [PATCH 51/76] fix(user): open the login when sharing to Talk without an account Sharing to Talk or opening it from another Nextcloud app goes directly to the conversation list. Without any account, the list closed immediately without any feedback. It now opens MainActivity, which starts the login. MainActivity ignores accounts scheduled for deletion, so it doesn't open the conversation list again while the last account is being removed, which has no default account then. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/activities/BaseActivity.kt | 3 +++ .../main/java/com/nextcloud/talk/activities/MainActivity.kt | 2 +- app/src/main/java/com/nextcloud/talk/users/UserManager.kt | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 2f7dced6ced..593d08b41a9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -149,6 +149,9 @@ open class BaseActivity : AppCompatActivity() { private fun requireBoundUserOrFinish(): User? = boundUser ?: run { Log.e(TAG, "No user found for id ${resolveUserIdFromIntent()}") + if (allowsDefaultAccount) { + startActivity(Intent(this, MainActivity::class.java)) + } finish() null } diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 33b60ba9341..a6a1b0edc97 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -256,7 +256,7 @@ class MainActivity : } } else { try { - val users = userManager.getUsers() + val users = userManager.getUsersNotScheduledForDeletion() if (isFinishing || isDestroyed) return@launch if (users.isNotEmpty()) { diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index 604de9ea9c8..d47f81a5559 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -34,6 +34,8 @@ class UserManager internal constructor(private val userRepository: UsersReposito suspend fun getUsersScheduledForDeletion(): List = userRepository.getUsersScheduledForDeletion() + suspend fun getUsersNotScheduledForDeletion(): List = userRepository.getUsersNotScheduledForDeletion() + /** * The default account, i.e. the last active user, or - if none is active - any user not scheduled for deletion, * which is then set as active. @@ -246,7 +248,6 @@ class UserManager internal constructor(private val userRepository: UsersReposito companion object { const val TAG = "UserManager" - private const val NO_ACTIVE_USER_ID = -1L } data class UserAttributes( From 9d1682969aef0ecc9e5ed256c63930438cdd07e0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 14:27:17 +0200 Subject: [PATCH 52/76] refactor(user): use Room partial entities for partial user updates The partial user updates are now @Update(entity = UserEntity::class) methods with partial entity classes instead of hand-written UPDATE queries. They still only write their own columns. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/data/user/UsersDao.kt | 33 ++++++++------- .../talk/data/user/UsersRepositoryImpl.kt | 15 ++++--- .../data/user/model/UserPartialUpdates.kt | 42 +++++++++++++++++++ .../utils/preview/ComposePreviewUtilsDaos.kt | 35 ++++++++-------- .../data/user/UsersDaoPartialUpdateTest.kt | 17 +++++--- 5 files changed, 99 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt index 5d03dfed2d4..cc5c5e52045 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt @@ -13,10 +13,12 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate import com.nextcloud.talk.data.user.model.UserEntity -import com.nextcloud.talk.models.ExternalSignalingServer -import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto -import com.nextcloud.talk.models.json.capabilities.ServerVersionDto +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow @@ -93,23 +95,24 @@ interface UsersDao { @Query("Update User SET pushConfigurationState = :state WHERE id == :id") suspend fun updatePushState(id: Long, state: PushConfigurationState): Int - // The following updates only write the given columns. Writing back a whole user row that was read earlier - // (e.g. before a network request) would reset columns that changed in the meantime, like "current". + // The following updates only write the columns of the given partial entity. Writing back a whole user row that + // was read earlier (e.g. before a network request) would reset columns that changed in the meantime, like + // "current". - @Query("UPDATE User SET capabilities = :capabilities, serverVersion = :serverVersion WHERE id == :id") - suspend fun updateCapabilities(id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto?): Int + @Update(entity = UserEntity::class) + suspend fun updateCapabilities(update: UserCapabilitiesUpdate): Int - @Query("UPDATE User SET externalSignalingServer = :externalSignalingServer WHERE id == :id") - suspend fun updateExternalSignalingServer(id: Long, externalSignalingServer: ExternalSignalingServer?): Int + @Update(entity = UserEntity::class) + suspend fun updateExternalSignalingServer(update: UserExternalSignalingServerUpdate): Int - @Query("UPDATE User SET displayName = :displayName WHERE id == :id") - suspend fun updateDisplayName(id: Long, displayName: String?): Int + @Update(entity = UserEntity::class) + suspend fun updateDisplayName(update: UserDisplayNameUpdate): Int - @Query("UPDATE User SET clientCertificate = :clientCertificate WHERE id == :id") - suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int + @Update(entity = UserEntity::class) + suspend fun updateClientCertificate(update: UserClientCertificateUpdate): Int - @Query("UPDATE User SET token = :token, clientCertificate = :clientCertificate WHERE id == :id") - suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int + @Update(entity = UserEntity::class) + suspend fun updateCredentials(update: UserCredentialsUpdate): Int companion object { const val TAG = "UsersDao" diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt index 8ce02c2a666..e995d3726de 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt @@ -9,6 +9,11 @@ package com.nextcloud.talk.data.user import android.util.Log import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto import com.nextcloud.talk.models.json.capabilities.ServerVersionDto @@ -71,21 +76,21 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto? - ): Int = usersDao.updateCapabilities(id, capabilities, serverVersion) + ): Int = usersDao.updateCapabilities(UserCapabilitiesUpdate(id, capabilities, serverVersion)) override suspend fun updateExternalSignalingServer( id: Long, externalSignalingServer: ExternalSignalingServer? - ): Int = usersDao.updateExternalSignalingServer(id, externalSignalingServer) + ): Int = usersDao.updateExternalSignalingServer(UserExternalSignalingServerUpdate(id, externalSignalingServer)) override suspend fun updateDisplayName(id: Long, displayName: String?): Int = - usersDao.updateDisplayName(id, displayName) + usersDao.updateDisplayName(UserDisplayNameUpdate(id, displayName)) override suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = - usersDao.updateClientCertificate(id, clientCertificate) + usersDao.updateClientCertificate(UserClientCertificateUpdate(id, clientCertificate)) override suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = - usersDao.updateCredentials(id, token, clientCertificate) + usersDao.updateCredentials(UserCredentialsUpdate(id, token, clientCertificate)) companion object { private val TAG = UsersRepositoryImpl::class.simpleName diff --git a/app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt b/app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt new file mode 100644 index 00000000000..336f94c1bf7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user.model + +import androidx.room.ColumnInfo +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto + +// Partial entities of UserEntity for Room's @Update(entity = UserEntity::class): each writes only its own columns +// of the user with the given id. + +data class UserCapabilitiesUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "capabilities") val capabilities: CapabilitiesDto?, + @ColumnInfo(name = "serverVersion") val serverVersion: ServerVersionDto? +) + +data class UserExternalSignalingServerUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "externalSignalingServer") val externalSignalingServer: ExternalSignalingServer? +) + +data class UserDisplayNameUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "displayName") val displayName: String? +) + +data class UserClientCertificateUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "clientCertificate") val clientCertificate: String? +) + +data class UserCredentialsUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "token") val token: String?, + @ColumnInfo(name = "clientCertificate") val clientCertificate: String? +) diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 99220629339..1c00bfb971c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -14,10 +14,12 @@ import com.nextcloud.talk.data.database.model.ChatBlockEntity import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.database.model.ConversationEntity import com.nextcloud.talk.data.user.UsersDao +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate import com.nextcloud.talk.data.user.model.UserEntity -import com.nextcloud.talk.models.ExternalSignalingServer -import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto -import com.nextcloud.talk.models.json.capabilities.ServerVersionDto +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -245,25 +247,22 @@ class DummyUserDaoImpl : UsersDao { } } - override suspend fun updateCapabilities( - id: Long, - capabilities: CapabilitiesDto?, - serverVersion: ServerVersionDto? - ): Int = updateDummyUser(id) { it.copy(capabilities = capabilities, serverVersion = serverVersion) } + override suspend fun updateCapabilities(update: UserCapabilitiesUpdate): Int = + updateDummyUser(update.id) { + it.copy(capabilities = update.capabilities, serverVersion = update.serverVersion) + } - override suspend fun updateExternalSignalingServer( - id: Long, - externalSignalingServer: ExternalSignalingServer? - ): Int = updateDummyUser(id) { it.copy(externalSignalingServer = externalSignalingServer) } + override suspend fun updateExternalSignalingServer(update: UserExternalSignalingServerUpdate): Int = + updateDummyUser(update.id) { it.copy(externalSignalingServer = update.externalSignalingServer) } - override suspend fun updateDisplayName(id: Long, displayName: String?): Int = - updateDummyUser(id) { it.copy(displayName = displayName) } + override suspend fun updateDisplayName(update: UserDisplayNameUpdate): Int = + updateDummyUser(update.id) { it.copy(displayName = update.displayName) } - override suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = - updateDummyUser(id) { it.copy(clientCertificate = clientCertificate) } + override suspend fun updateClientCertificate(update: UserClientCertificateUpdate): Int = + updateDummyUser(update.id) { it.copy(clientCertificate = update.clientCertificate) } - override suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = - updateDummyUser(id) { it.copy(token = token, clientCertificate = clientCertificate) } + override suspend fun updateCredentials(update: UserCredentialsUpdate): Int = + updateDummyUser(update.id) { it.copy(token = update.token, clientCertificate = update.clientCertificate) } private fun updateDummyUser(id: Long, update: (UserEntity) -> UserEntity): Int { val index = dummyUsers.indexOfFirst { it.id == id } diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt index 0a32226fb39..9532a5b833d 100644 --- a/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt @@ -11,7 +11,12 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.nextcloud.talk.data.source.local.TalkDatabase +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto import com.nextcloud.talk.models.json.capabilities.ServerVersionDto @@ -60,11 +65,13 @@ class UsersDaoPartialUpdateTest { runBlocking { dao.setUserAsActiveWithId(USER_B) - dao.updateCapabilities(USER_A, CapabilitiesDto(), ServerVersionDto(major = 30)) - dao.updateExternalSignalingServer(USER_A, ExternalSignalingServer(externalSignalingServer = "wss://s")) - dao.updateDisplayName(USER_A, "Alice") - dao.updateClientCertificate(USER_A, "alias") - dao.updateCredentials(USER_A, "newToken", "alias") + dao.updateCapabilities(UserCapabilitiesUpdate(USER_A, CapabilitiesDto(), ServerVersionDto(major = 30))) + dao.updateExternalSignalingServer( + UserExternalSignalingServerUpdate(USER_A, ExternalSignalingServer(externalSignalingServer = "wss://s")) + ) + dao.updateDisplayName(UserDisplayNameUpdate(USER_A, "Alice")) + dao.updateClientCertificate(UserClientCertificateUpdate(USER_A, "alias")) + dao.updateCredentials(UserCredentialsUpdate(USER_A, "newToken", "alias")) val userA = dao.getUserWithId(USER_A)!! assertFalse(userA.current) From 5780024eeff4a570714e41163ce0cba77e6c51f2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 16:33:10 +0200 Subject: [PATCH 53/76] fix(theming): key the color scheme cache by the theming capability The color scheme cache was keyed by the server URL only. Accounts on the same server can have different colors (e.g. a personal primary color), so with per-account theming the second account got the colors of the first one. Changed server colors were also only used after an app restart. The cache is now keyed by the theming capability. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ui/theme/MaterialSchemesProviderImpl.kt | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt index a911d1c4d83..871482f4f0b 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt @@ -11,6 +11,7 @@ import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ThemingCapabilityDto import com.nextcloud.talk.users.DefaultAccountProvider import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -20,20 +21,15 @@ internal class MaterialSchemesProviderImpl @Inject constructor( private val colorUtil: ColorUtil ) : MaterialSchemesProvider { - private val themeCache: ConcurrentHashMap = ConcurrentHashMap() + // Keyed by the theming capability instead of the server: accounts on the same server can have different colors + // (e.g. a personal primary color), and changed colors are used after the next capabilities sync. + private val themeCache: ConcurrentHashMap = ConcurrentHashMap() override fun getMaterialSchemesForUser(user: User?): MaterialSchemes { - val url: String = if (user?.baseUrl != null) { - user.baseUrl!! - } else { - FALLBACK_URL - } - - if (!themeCache.containsKey(url)) { - themeCache[url] = getMaterialSchemesForCapabilities(user?.capabilities) - } - - return themeCache[url]!! + val capabilities = user?.capabilities + // A copy, so a later change of the capability object cannot change the key of a cached entry. + val key = ThemeKey(capabilities?.themingCapability?.copy()) + return themeCache.getOrPut(key) { getMaterialSchemesForCapabilities(capabilities) } } override fun getMaterialSchemesForDefaultUser(): MaterialSchemes = @@ -44,7 +40,5 @@ internal class MaterialSchemesProviderImpl @Inject constructor( return MaterialSchemes.fromServerTheme(serverTheme) } - companion object { - const val FALLBACK_URL = "NULL" - } + private data class ThemeKey(val themingCapability: ThemingCapabilityDto?) } From 44db93aaa41d591d0f14836f2f8125fe700d818f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 16:52:16 +0200 Subject: [PATCH 54/76] fix(conversationlist): rename conversations with the account of the list The rename dialog creates its own ConversationInfoEditViewModel without initializing it, so renameRoom() had no account and returned without sending the request or showing an error. The dialog now gets the account of the conversation list as an argument and passes it to renameRoom(). Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../conversation/RenameConversationDialogFragment.kt | 10 ++++++++-- .../viewmodel/ConversationInfoEditViewModel.kt | 3 +-- .../talk/conversationlist/ConversationsListActivity.kt | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt index 16c6d8ea6b0..95b6faa857f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt @@ -16,6 +16,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider @@ -28,6 +29,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogRenameConversationBinding import com.nextcloud.talk.events.ConversationsListFetchDataEvent import com.nextcloud.talk.logger.Logger @@ -55,6 +57,7 @@ class RenameConversationDialogFragment : DialogFragment() { private lateinit var binding: DialogRenameConversationBinding private lateinit var viewModel: ConversationInfoEditViewModel + private lateinit var user: User private var roomToken = "" private var initialName = "" @@ -65,6 +68,7 @@ class RenameConversationDialogFragment : DialogFragment() { viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] + user = BundleCompat.getParcelable(requireArguments(), KEY_USER, User::class.java)!! roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! initialName = arguments?.getString(INITIAL_NAME)!! } @@ -102,7 +106,7 @@ class RenameConversationDialogFragment : DialogFragment() { val positiveButton = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) positiveButton.isEnabled = false positiveButton.setOnClickListener { - viewModel.renameRoom(roomToken, binding.textEdit.text.toString()) + viewModel.renameRoom(user, roomToken, binding.textEdit.text.toString()) } themeDialog() @@ -187,12 +191,14 @@ class RenameConversationDialogFragment : DialogFragment() { */ companion object { private val TAG = RenameConversationDialogFragment::class.java.simpleName + private const val KEY_USER = "keyUser" private const val KEY_ROOM_TOKEN = "keyRoomToken" private const val INITIAL_NAME = "initialName" @JvmStatic - fun newInstance(roomTokenParam: String, initialName: String): RenameConversationDialogFragment { + fun newInstance(user: User, roomTokenParam: String, initialName: String): RenameConversationDialogFragment { val args = Bundle() + args.putParcelable(KEY_USER, user) args.putString(KEY_ROOM_TOKEN, roomTokenParam) args.putString(INITIAL_NAME, initialName) val fragment = RenameConversationDialogFragment() diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt index 46162f3ae9d..944b1a3339a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt @@ -176,10 +176,9 @@ class ConversationInfoEditViewModel @Inject constructor( } @Suppress("Detekt.TooGenericExceptionCaught") - fun renameRoom(token: String, newName: String) { + fun renameRoom(user: User, token: String, newName: String) { viewModelScope.launch { try { - val user = currentUser ?: return@launch conversationInfoEditRepository.renameConversation(user, token, newName) _uiState.update { it.copy(navigateBack = true) } } catch (exception: Exception) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 8c63e65a57b..0b30fbcf90b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1287,9 +1287,10 @@ class ConversationsListActivity : BaseActivity() { } private fun renameConversation(conversation: ConversationModel) { + val user = currentUser ?: return if (!TextUtils.isEmpty(conversation.token)) { RenameConversationDialogFragment - .newInstance(conversation.token!!, conversation.displayName!!) + .newInstance(user, conversation.token!!, conversation.displayName!!) .show(supportFragmentManager, RenameConversationDialogFragment::class.simpleName) } } From dfc6b25660d53ede34951e55022b4dedbdbecc5f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 16:57:00 +0200 Subject: [PATCH 55/76] refactor(conversationlist): make the account of the list non-null The conversation list assigns its bound account in onCreate() before anything reads it, so it is now a lateinit property instead of a nullable one. This removes the null checks and the unreachable "currentUser was null" branch. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 160 ++++++++---------- 1 file changed, 71 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 0b30fbcf90b..32655f586e5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -151,17 +151,17 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var contactsViewModelFactory: ContactsViewModel.Factory - val contactsViewModel: ContactsViewModel by assistedViewModels { contactsViewModelFactory.build(currentUser!!) } + val contactsViewModel: ContactsViewModel by assistedViewModels { contactsViewModelFactory.build(currentUser) } val conversationsListViewModel: ConversationsListViewModel by assistedViewModels { - conversationsListViewModelFactory.build(currentUser!!) + conversationsListViewModelFactory.build(currentUser) } val conversationTagsViewModel: ConversationTagsViewModel by assistedViewModels { - conversationTagsViewModelFactory.build(currentUser!!) + conversationTagsViewModelFactory.build(currentUser) } - private var currentUser: User? = null + private lateinit var currentUser: User private val snackbarHostState = SnackbarHostState() private val isMaintenanceModeState = MutableStateFlow(false) private val showUnreadBubbleState = MutableStateFlow(false) @@ -205,13 +205,12 @@ class ConversationsListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return - currentUser = user + currentUser = setUpBoundUserOrFinish() ?: return ecosystemManager = EcosystemManager(this@ConversationsListActivity) - if (!user.current) { + if (!currentUser.current) { // The shown account becomes the last used one, which the account switcher and status views rely on. - lifecycleScope.launch { userManager.setUserAsActive(user) } + lifecycleScope.launch { userManager.setUserAsActive(currentUser) } } setSupportActionBar(null) @@ -244,7 +243,7 @@ class ConversationsListActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) - if (newUserId != 0L && newUserId != currentUser?.id) { + if (newUserId != 0L && newUserId != currentUser.id) { // The list is bound to one account, so open a fresh instance for the other one. The activity is // exported, so the received intent is not launched again as it is: the new one only targets this // activity and takes over no flags like URI permission grants. @@ -257,7 +256,7 @@ class ConversationsListActivity : BaseActivity() { startActivity(accountIntent) return } - currentUser?.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } + currentUser.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } setIntent(intent) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) @@ -331,12 +330,12 @@ class ConversationsListActivity : BaseActivity() { bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) val settingsIntent = Intent(context, SettingsActivity::class.java) settingsIntent.putExtras(bundle) - settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(settingsIntent) }, onFederationHintClick = { val intent = Intent(context, InvitationsActivity::class.java) - intent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + intent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(intent) }, onFilterClick = { @@ -353,7 +352,7 @@ class ConversationsListActivity : BaseActivity() { showChooseAccountDialog() } else { val settingsIntent = Intent(context, SettingsActivity::class.java) - settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(settingsIntent) } }, @@ -426,7 +425,7 @@ class ConversationsListActivity : BaseActivity() { // would lose the pending message. val defaultUserId = defaultAccountProvider.getDefaultUserBlocking()?.id ?: return val isPickingConversation = forwardMessage || hasActivityActionSendIntent() - if (defaultUserId != currentUser?.id && !isPickingConversation) { + if (defaultUserId != currentUser.id && !isPickingConversation) { startActivity(createAccountSwitchIntent(this, defaultUserId)) } } @@ -456,27 +455,22 @@ class ConversationsListActivity : BaseActivity() { eventBus.register(this) } - if (currentUser != null) { - if (isServerEOL(currentUser!!.serverVersion?.major)) { - showServerEOLDialog() - return - } - credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + if (isServerEOL(currentUser.serverVersion?.major)) { + showServerEOLDialog() + return + } + credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) - if (currentUser!!.id != appPreferences.getConversationListLastUserId()) { - appPreferences.setConversationListPositionAndOffset(0, 0) - } + if (currentUser.id != appPreferences.getConversationListLastUserId()) { + appPreferences.setConversationListPositionAndOffset(0, 0) + } - lifecycleScope.launch { - hasMultipleAccountsState.value = userManager.getUsers().size > 1 - } - conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) - fetchRooms() - fetchPendingInvitations() - } else { - logger.e(TAG, "currentUser was null") - showSnackbar(getString(R.string.nc_common_error_sorry)) + lifecycleScope.launch { + hasMultipleAccountsState.value = userManager.getUsers().size > 1 } + conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) + fetchRooms() + fetchPendingInvitations() conversationsListViewModel.checkIfThreadsExist() conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) @@ -489,7 +483,7 @@ class ConversationsListActivity : BaseActivity() { val firstOffset = state.layoutInfo.visibleItemsInfo.firstOrNull()?.offset ?: 0 appPreferences.setConversationListPositionAndOffset(state.firstVisibleItemIndex, firstOffset) } - appPreferences.setConversationListLastUserId(currentUser?.id ?: -1L) + appPreferences.setConversationListLastUserId(currentUser.id ?: -1L) } @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -523,14 +517,8 @@ class ConversationsListActivity : BaseActivity() { handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "") // Update Direct Share targets - if (currentUser != null) { - lifecycleScope.launch { - DirectShareHelper.publishShareTargetShortcuts( - context, - currentUser!!, - list - ) - } + lifecycleScope.launch { + DirectShareHelper.publishShareTargetShortcuts(context, currentUser, list) } // check for Direct Share @@ -545,9 +533,7 @@ class ConversationsListActivity : BaseActivity() { } // Update dynamic shortcuts for frequent/favorite conversations - currentUser?.let { user -> - ShortcutManagerHelper.updateDynamicShortcuts(context, list, user) - } + ShortcutManagerHelper.updateDynamicShortcuts(context, list, currentUser) if (!scrollPositionRestored) { scrollPositionRestored = true @@ -639,7 +625,7 @@ class ConversationsListActivity : BaseActivity() { private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { if (noteToSelfAvailable) { - val intent = ChatActivity.createIntent(context, currentUser!!.id!!, noteToSelfToken) + val intent = ChatActivity.createIntent(context, currentUser.id!!, noteToSelfToken) intent.putExtra(BundleKeys.KEY_FOCUS_INPUT, true) intent.action = Intent.ACTION_VIEW val openNotesString = resources.getString(R.string.open_notes) @@ -683,7 +669,7 @@ class ConversationsListActivity : BaseActivity() { return } val threadId = result.threadId?.toLongOrNull()?.takeIf { it > 0L } - val intent = ChatActivity.createIntent(context, currentUser!!.id!!, result.conversationToken).apply { + val intent = ChatActivity.createIntent(context, currentUser.id!!, result.conversationToken).apply { putExtra(BundleKeys.KEY_MESSAGE_ID, messageId) putExtra(BundleKeys.KEY_SEARCH_QUERY, result.searchTerm) threadId?.let { putExtra(BundleKeys.KEY_THREAD_ID, it) } @@ -727,11 +713,11 @@ class ConversationsListActivity : BaseActivity() { } fun fetchRooms() { - conversationsListViewModel.getRooms(currentUser!!) + conversationsListViewModel.getRooms(currentUser) } private fun fetchPendingInvitations() { - if (hasSpreedFeatureCapability(currentUser?.capabilities?.spreedCapability, SpreedFeatures.FEDERATION_V1)) { + if (hasSpreedFeatureCapability(currentUser.capabilities?.spreedCapability, SpreedFeatures.FEDERATION_V1)) { conversationsListViewModel.getFederationInvitations() } } @@ -836,7 +822,7 @@ class ConversationsListActivity : BaseActivity() { private fun showNewConversationsScreen() { val intent = Intent(context, ContactsActivity::class.java) - intent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + intent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(intent) } @@ -849,7 +835,7 @@ class ConversationsListActivity : BaseActivity() { selectedConversation = conversation if (selectedConversation != null) { val hasChatPermission = ParticipantPermissions( - currentUser?.capabilities?.spreedCapability, + currentUser.capabilities?.spreedCapability, selectedConversation!! ) .hasChatPermission() @@ -879,11 +865,11 @@ class ConversationsListActivity : BaseActivity() { private fun shouldShowLobby(conversation: ConversationModel): Boolean { val participantPermissions = ParticipantPermissions( - currentUser?.capabilities?.spreedCapability, + currentUser.capabilities?.spreedCapability, selectedConversation!! ) return conversation.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY && - !ConversationUtils.canModerate(conversation, currentUser?.capabilities?.spreedCapability) && + !ConversationUtils.canModerate(conversation, currentUser.capabilities?.spreedCapability) && !participantPermissions.canIgnoreLobby() } @@ -1094,7 +1080,7 @@ class ConversationsListActivity : BaseActivity() { val callsChannelNotEnabled = !NotificationUtils.isCallsNotificationChannelEnabled(this) val serverNotificationAppInstalled = - currentUser?.capabilities?.notificationsCapability?.features?.isNotEmpty() == true + currentUser.capabilities?.notificationsCapability?.features?.isNotEmpty() == true val settingsOfUserAreWrong = notificationPermissionNotGranted || batteryOptimizationNotIgnored || @@ -1115,13 +1101,13 @@ class ConversationsListActivity : BaseActivity() { private fun getIntentForCall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean): Intent? { selectedConversation?.let { - val pp = ParticipantPermissions(currentUser?.capabilities?.spreedCapability, it) + val pp = ParticipantPermissions(currentUser.capabilities?.spreedCapability, it) val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, it.token) - bundle.putLong(KEY_INTERNAL_USER_ID, currentUser!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, "") - bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, currentUser?.baseUrl!!) + bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, currentUser.baseUrl!!) bundle.putString(KEY_CONVERSATION_NAME, it.displayName) bundle.putInt(KEY_RECORDING_STATE, it.callRecording) bundle.putBoolean(KEY_IS_MODERATOR, ConversationUtils.isParticipantOwnerOrModerator(it)) @@ -1163,22 +1149,20 @@ class ConversationsListActivity : BaseActivity() { selectedMessageId = null } - val chatIntent = ChatActivity.createIntent(context, currentUser!!.id!!, it.token, bundle) + val chatIntent = ChatActivity.createIntent(context, currentUser.id!!, it.token, bundle) - if (currentUser != null) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(currentUser?.capabilities?.spreedCapability)) { - showSnackbar(context.getString(R.string.nc_call_e2ee_not_supported)) - return@let - } - val pp = ParticipantPermissions(currentUser?.capabilities?.spreedCapability, it) - if (!pp.canStartCall() && selectedConversation?.hasCall == false) { - Log.e(TAG, "Error starting call from conversations list: call is forbidden") - } else { - ApplicationWideCurrentRoomHolder.getInstance().isDialing = true - val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) - if (callIntent != null) { - startActivities(arrayOf(chatIntent, callIntent)) - } + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(currentUser.capabilities?.spreedCapability)) { + showSnackbar(context.getString(R.string.nc_call_e2ee_not_supported)) + return@let + } + val pp = ParticipantPermissions(currentUser.capabilities?.spreedCapability, it) + if (!pp.canStartCall() && selectedConversation?.hasCall == false) { + Log.e(TAG, "Error starting call from conversations list: call is forbidden") + } else { + ApplicationWideCurrentRoomHolder.getInstance().isDialing = true + val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) + if (callIntent != null) { + startActivities(arrayOf(chatIntent, callIntent)) } } } @@ -1202,7 +1186,7 @@ class ConversationsListActivity : BaseActivity() { selectedMessageId = null } - val intent = ChatActivity.createIntent(context, currentUser!!.id!!, selectedConversation!!.token, bundle) + val intent = ChatActivity.createIntent(context, currentUser.id!!, selectedConversation!!.token, bundle) startActivity(intent) clearIntentAction() @@ -1210,7 +1194,7 @@ class ConversationsListActivity : BaseActivity() { @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { - if (currentUser != null && eventStatus.userId == currentUser!!.id) { + if (eventStatus.userId == currentUser.id) { when (eventStatus.eventType) { EventStatus.EventType.CONVERSATION_UPDATE -> if (eventStatus.isAllGood && !isRefreshingState.value) { fetchRooms() @@ -1246,18 +1230,17 @@ class ConversationsListActivity : BaseActivity() { } private fun shareConversationLink(conversation: ConversationModel) { - val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(currentUser!!) + val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(currentUser) ShareUtils.shareConversationLink( this, - currentUser?.baseUrl, + currentUser.baseUrl, conversation.token, canGeneratePrettyURL ) } private fun addConversationToHomeScreen(conversation: ConversationModel) { - val user = currentUser ?: return - val success = ShortcutManagerHelper.requestPinShortcut(this, conversation, user) + val success = ShortcutManagerHelper.requestPinShortcut(this, conversation, currentUser) if (success) { showSnackbar(resources.getString(R.string.nc_shortcut_created)) } else { @@ -1287,10 +1270,9 @@ class ConversationsListActivity : BaseActivity() { } private fun renameConversation(conversation: ConversationModel) { - val user = currentUser ?: return if (!TextUtils.isEmpty(conversation.token)) { RenameConversationDialogFragment - .newInstance(user, conversation.token!!, conversation.displayName!!) + .newInstance(currentUser, conversation.token!!, conversation.displayName!!) .show(supportFragmentManager, RenameConversationDialogFragment::class.simpleName) } } @@ -1322,7 +1304,7 @@ class ConversationsListActivity : BaseActivity() { val token = conversation.token ?: return val data = Data.Builder() .putString(KEY_ROOM_TOKEN, token) - .putLong(KEY_INTERNAL_USER_ID, currentUser?.id!!) + .putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) .build() val worker = OneTimeWorkRequest.Builder(LeaveConversationWorker::class.java) .setInputData(data) @@ -1332,7 +1314,7 @@ class ConversationsListActivity : BaseActivity() { WorkManager.getInstance(this).getWorkInfoByIdLiveData(worker.id).observeForever { workInfo -> when (workInfo?.state) { WorkInfo.State.SUCCEEDED -> { - currentUser?.id?.let { userId -> + currentUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( this, token, @@ -1415,7 +1397,7 @@ class ConversationsListActivity : BaseActivity() { .setNegativeButton(R.string.nc_settings_reauthorize) { _, _ -> val intent = Intent(context, BrowserLoginActivity::class.java) val bundle = Bundle() - bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl!!) + bundle.putString(BundleKeys.KEY_BASE_URL, currentUser.baseUrl!!) bundle.putBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, true) intent.putExtras(bundle) startActivity(intent) @@ -1431,7 +1413,7 @@ class ConversationsListActivity : BaseActivity() { private fun deleteUserAndRestartApp() { lifecycleScope.launch { - userManager.scheduleUserForDeletionWithId(currentUser!!.id!!) + userManager.scheduleUserForDeletionWithId(currentUser.id!!) val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java) .setExpeditedIfSupported() .build() @@ -1444,7 +1426,7 @@ class ConversationsListActivity : BaseActivity() { WorkInfo.State.SUCCEEDED -> { val text = String.format( context.resources.getString(R.string.nc_deleted_user), - currentUser!!.displayName + currentUser.displayName ) Toast.makeText( context, @@ -1455,7 +1437,7 @@ class ConversationsListActivity : BaseActivity() { } WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - logger.e(TAG, "something went wrong when deleting user with id " + currentUser!!.userId) + logger.e(TAG, "something went wrong when deleting user with id " + currentUser.userId) Toast.makeText( context, @@ -1569,7 +1551,7 @@ class ConversationsListActivity : BaseActivity() { val data = Data.Builder() data.putLong( KEY_INTERNAL_USER_ID, - currentUser?.id!! + currentUser.id!! ) data.putString(KEY_ROOM_TOKEN, conversation.token) @@ -1585,7 +1567,7 @@ class ConversationsListActivity : BaseActivity() { if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { - currentUser?.id?.let { userId -> + currentUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( context, conversation.token, @@ -1616,7 +1598,7 @@ class ConversationsListActivity : BaseActivity() { fun openFollowedThreadsOverview() { val threadsUrl = ApiUtils.getUrlForSubscribedThreads( version = 1, - baseUrl = currentUser!!.baseUrl + baseUrl = currentUser.baseUrl ) val bundle = Bundle() @@ -1624,7 +1606,7 @@ class ConversationsListActivity : BaseActivity() { bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java) threadsOverviewIntent.putExtras(bundle) - threadsOverviewIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser!!.id) + threadsOverviewIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(threadsOverviewIntent) } From 03944c400d451235b2b87e97a389f916af8e585f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 17:06:48 +0200 Subject: [PATCH 56/76] fix(profile): close profile and settings when their account was removed Profile and settings load their account again, in onResume() and after the capabilities sync, and then accessed it with !!. When the account had been removed in the meantime, this crashed. They now finish instead. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/profile/ProfileActivity.kt | 6 +++++- .../talk/settings/SettingsActivity.kt | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index 25c7bf687fa..123ca3c7084 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -170,7 +170,11 @@ class ProfileActivity : BaseActivity() { override fun onResume() { super.onResume() - currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { + // The account was removed while this screen was in the background. + finish() + return + } val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) pickImage = PickImage(this, currentUser) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 60cd1652dbd..48ac99d0b66 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -185,7 +185,7 @@ class SettingsActivity : binding.avatarImage.let { ViewCompat.setTransitionName(it, "userAvatar.transitionTag") } - getCurrentUser() + if (!getCurrentUser()) return handleIntent(intent) setupLicenceSetting(isOnline.value) @@ -315,8 +315,7 @@ class SettingsActivity : WorkManager.getInstance(context).getWorkInfoByIdLiveData(capabilitiesWork.id) .observe(this) { workInfo -> - if (workInfo?.state == WorkInfo.State.SUCCEEDED) { - getCurrentUser() + if (workInfo?.state == WorkInfo.State.SUCCEEDED && getCurrentUser()) { setupCheckables(isOnline) } } @@ -334,9 +333,17 @@ class SettingsActivity : viewThemeUtils.material.themeToolbar(binding.settingsToolbar) } - private fun getCurrentUser() { - currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } - credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + /** + * Loads the account of this screen again. Finishes the activity and returns false if it was removed meanwhile. + */ + private fun getCurrentUser(): Boolean { + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { + finish() + return false + } + currentUser = user + credentials = ApiUtils.getCredentials(user.username, user.token) + return true } private fun setupPhoneBookIntegration(isOnline: Boolean) { From d7d8a40dff2ac492fde16d173978a9de8e9ade4b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 29 Sep 2026 17:10:06 +0200 Subject: [PATCH 57/76] suppress large class for now Signed-off-by: Marcel Hibbe --- .../conversationlist/viewmodels/ConversationsListViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index 5fab5406286..2b44f18b988 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -69,7 +69,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -@Suppress("LongParameterList", "TooManyFunctions", "Detekt.LongMethod") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") class ConversationsListViewModel @AssistedInject constructor( private val repository: OfflineConversationsRepository, private val threadsRepository: ThreadsRepository, From 1ab979c91673ea1079b8db9602c4a2b775e114f2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 09:41:03 +0200 Subject: [PATCH 58/76] fix(conversationlist): keep shared files when relaunching the list for another account When an intent for another account reached the conversation list via onNewIntent, the new list only got the action, data and extras of it. Shared files in the clip data, the type and the read access to the files were lost. Both relaunches for another account now use relaunchForAccount(), which replaces continueShareWithAccount() and takes over all of them. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 35 ++++++++----------- .../ChooseAccountShareToDialogFragment.kt | 2 +- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 32655f586e5..8070c1821ca 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -244,16 +244,7 @@ class ConversationsListActivity : BaseActivity() { super.onNewIntent(intent) val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) if (newUserId != 0L && newUserId != currentUser.id) { - // The list is bound to one account, so open a fresh instance for the other one. The activity is - // exported, so the received intent is not launched again as it is: the new one only targets this - // activity and takes over no flags like URI permission grants. - val accountIntent = Intent(this, ConversationsListActivity::class.java).apply { - action = intent.action - data = intent.data - intent.extras?.let { putExtras(it) } - } - finish() - startActivity(accountIntent) + relaunchForAccount(newUserId, intent) return } currentUser.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } @@ -686,22 +677,24 @@ class ConversationsListActivity : BaseActivity() { } /** - * Continues the pending share in the conversation list of the account with the internal id [userId]. + * Replaces this list by a new one for the account with the internal id [userId], e.g. for an intent for another + * account or to continue a pending share with another account. * - * The list is bound to its account, so it is replaced by a new one for that account instead of being recreated, - * which would keep the account and its view models. The new list gets the shared content and the read access to - * the shared files, which ends with this activity. + * The list is bound to its account, so it is not recreated, which would keep the account and its view models. + * The new list gets the action, data and extras of [source], including shared content and the read access to + * shared files, which ends with this activity. The activity is exported, so [source] can come from another app: + * the new intent only targets this activity and takes over no other flags. */ - fun continueShareWithAccount(userId: Long) { - val shareIntent = Intent(this, ConversationsListActivity::class.java).apply { - action = intent.action - setDataAndType(intent.data, intent.type) - intent.extras?.let { putExtras(it) } - clipData = intent.clipData + fun relaunchForAccount(userId: Long, source: Intent = intent) { + val accountIntent = Intent(this, ConversationsListActivity::class.java).apply { + action = source.action + setDataAndType(source.data, source.type) + source.extras?.let { putExtras(it) } + clipData = source.clipData putExtra(KEY_INTERNAL_USER_ID, userId) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - startActivity(shareIntent) + startActivity(accountIntent) finish() } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt index 1f8074760a1..3b6a9a0fac3 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt @@ -76,7 +76,7 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { } is SwitchUserSuccessStateChooseAccountShareTo -> { cookieManager.cookieStore.removeAll() - (activity as? ConversationsListActivity)?.continueShareWithAccount(state.user.id!!) + (activity as? ConversationsListActivity)?.relaunchForAccount(state.user.id!!) dismiss() } else -> {} From 548cc2325c03818c0bf317c66ba26457e6f0851e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 10:36:51 +0200 Subject: [PATCH 59/76] fix(login): reauthorize only the account that logged in A reauthorization stored the new app password in the default account, no matter which account logged in. The login flow grants an app password for whoever is logged in in the browser, e.g. another account on the same server with an active web session. That account's token then ended up in the default account, so its requests were authenticated as the other account: it showed the other account's conversations and got 404 for its own. The reauthorization now gets the account to reauthorize, and only stores the new token in the account that logged in, found by login name and server. If it's a different account than the one to reauthorize, nothing is changed and the login screen asks to log in with the right account. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/account/BrowserLoginActivity.kt | 15 ++++-- .../talk/account/data/LoginRepository.kt | 53 +++++++++++++++---- .../account/data/io/LocalLoginDataSource.kt | 23 ++++---- .../BrowserLoginActivityViewModel.kt | 44 ++++++--------- .../ConversationsListActivity.kt | 1 + .../com/nextcloud/talk/users/UserManager.kt | 5 +- app/src/main/res/values/strings.xml | 1 + .../talk/login/data/LoginRepositoryTest.kt | 52 +++++++++++++----- 8 files changed, 128 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt index 00ddd498d96..e5c443eadea 100644 --- a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt @@ -115,6 +115,13 @@ class BrowserLoginActivity : BaseActivity() { BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRestartApp -> { restartApp() } + BrowserLoginActivityViewModel.PostLoginViewState.PostLoginDifferentAccount -> { + Snackbar.make( + binding.root, + R.string.nc_reauthorize_different_account, + Snackbar.LENGTH_LONG + ).show() + } } } } @@ -128,17 +135,19 @@ class BrowserLoginActivity : BaseActivity() { if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) { reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT) } + // The account to reauthorize, so a login with another account doesn't change it. + val accountToReauthorize = extras.getLong(BundleKeys.KEY_INTERNAL_USER_ID, 0L).takeIf { it != 0L } if (extras.containsKey(BundleKeys.KEY_FROM_QR)) { val uri = extras.getString(BundleKeys.KEY_FROM_QR)!! if (uri.startsWith(LoginRepository.ONE_TIME_PREFIX)) { - viewModel.loginWithOTPQR(uri, reauthorizeAccount) + viewModel.loginWithOTPQR(uri, reauthorizeAccount, accountToReauthorize) } else { - viewModel.loginWithQR(uri, reauthorizeAccount) + viewModel.loginWithQR(uri, reauthorizeAccount, accountToReauthorize) } } else if (baseUrl != null) { - viewModel.startWebBrowserLogin(baseUrl, reauthorizeAccount) + viewModel.startWebBrowserLogin(baseUrl, reauthorizeAccount, accountToReauthorize) } } diff --git a/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt b/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt index 9699778cb15..82f9986f46a 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt @@ -39,7 +39,22 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin private const val MAX_ARGS = 3 } + /** + * Result of [parseAndLogin]. + */ + sealed interface LoginResult { + /** A new account, to be verified and stored with the data in [bundle]. */ + data class NewAccount(val bundle: Bundle) : LoginResult + + /** An existing account, reauthorized or not, or an account that is being removed. */ + data object ExistingAccount : LoginResult + + /** A reauthorization in which a different account than the one to reauthorize logged in. */ + data object DifferentAccount : LoginResult + } + private var shouldReauthorizeUser = false + private var accountToReauthorize: Long? = null private var shouldLoop = true suspend fun pollLogin(response: LoginResponse): LoginCompletion? = @@ -63,8 +78,13 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin * Entry point for QR scanner * */ - fun startLoginFlowFromQR(dataString: String, reAuth: Boolean = false): LoginCompletion? { + fun startLoginFlowFromQR( + dataString: String, + reAuth: Boolean = false, + accountToReauthorize: Long? = null + ): LoginCompletion? { shouldReauthorizeUser = reAuth + this.accountToReauthorize = accountToReauthorize if (!dataString.startsWith(PREFIX)) { Log.e(TAG, "Invalid login URL detected") @@ -108,9 +128,14 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin /** * Entry point for a one-time QR code */ - suspend fun startOTPLoginFlow(dataString: String, reAuth: Boolean = false): LoginCompletion? = + suspend fun startOTPLoginFlow( + dataString: String, + reAuth: Boolean = false, + accountToReauthorize: Long? = null + ): LoginCompletion? = withContext(Dispatchers.IO) { shouldReauthorizeUser = reAuth + this@LoginRepository.accountToReauthorize = accountToReauthorize if (!dataString.startsWith(ONE_TIME_PREFIX)) { Log.e(TAG, "Invalid login URL detected") @@ -158,9 +183,14 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin /** * Entry point to the login process */ - suspend fun startLoginFlow(baseUrl: String, reAuth: Boolean = false): LoginResponse? = + suspend fun startLoginFlow( + baseUrl: String, + reAuth: Boolean = false, + accountToReauthorize: Long? = null + ): LoginResponse? = withContext(Dispatchers.IO) { shouldReauthorizeUser = reAuth + this@LoginRepository.accountToReauthorize = accountToReauthorize val response = network.anonymouslyPostLoginRequest(baseUrl) return@withContext response } @@ -173,23 +203,28 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin } /** - * Returns bundle if user is not scheduled for deletion or doesn't already exist, null otherwise + * Returns [LoginResult.NewAccount] if the account is not scheduled for deletion and doesn't exist yet. For an + * existing account being reauthorized, [LoginResult.DifferentAccount] if another account than the one to + * reauthorize logged in, which is then left unchanged. */ - suspend fun parseAndLogin(loginData: LoginCompletion): Bundle? { + suspend fun parseAndLogin(loginData: LoginCompletion): LoginResult { if (local.checkIfUserIsScheduledForDeletion(loginData)) { // however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it. local.startAccountRemovalWorker() - return null + return LoginResult.ExistingAccount } else if (local.checkIfUserExists(loginData)) { if (shouldReauthorizeUser) { - local.updateUser(loginData) + if (!local.updateUser(loginData, accountToReauthorize)) { + Log.w(TAG, "Logged in with a different account than the one to reauthorize. Skipped update.") + return LoginResult.DifferentAccount + } } else { Log.w(TAG, "Tried to add an account that account already exists. Skipped user creation.") } - return null + return LoginResult.ExistingAccount } else { - return startAccountVerification(loginData) + return LoginResult.NewAccount(startAccountVerification(loginData)) } } diff --git a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt index 826bf1c84d2..bb85507086c 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt @@ -17,23 +17,22 @@ import com.nextcloud.talk.account.data.model.LoginCompletion import com.nextcloud.talk.jobs.AccountRemovalWorker import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.preferences.AppPreferences -import kotlinx.coroutines.runBlocking // local datasource for communicating with room through account manager // crucial for making sure the login process interacts with the db as expected. class LocalLoginDataSource(val userManager: UserManager, val appPreferences: AppPreferences, val context: Context) { - fun updateUser(loginData: LoginCompletion) { - val currentUser = runBlocking { userManager.getDefaultUser() } - if (currentUser != null) { - runBlocking { - userManager.updateCredentials( - currentUser.id!!, - loginData.appPassword, - appPreferences.temporaryClientCertAlias - ) - } - } + /** + * Stores the new credentials of a reauthorized account. That is the account that logged in, found by its login + * name and server, as the browser login flow grants an app password for whoever is logged in there. Returns false + * without any change if it is not the account with the internal id [accountToReauthorize]. + */ + suspend fun updateUser(loginData: LoginCompletion, accountToReauthorize: Long?): Boolean { + val user = userManager.getUserWithUsernameAndServer(loginData.loginName, loginData.server) + ?.takeIf { accountToReauthorize == null || it.id == accountToReauthorize } + ?: return false + userManager.updateCredentials(user.id!!, loginData.appPassword, appPreferences.temporaryClientCertAlias) + return true } fun startAccountRemovalWorker(): LiveData { diff --git a/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt b/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt index de3ae70b7c6..1100f2a1b96 100644 --- a/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt @@ -38,6 +38,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep data object PostLoginRestartApp : PostLoginViewState() data object PostLoginError : PostLoginViewState() data class PostLoginContinue(val data: Bundle) : PostLoginViewState() + data object PostLoginDifferentAccount : PostLoginViewState() } private val _postLoginState = MutableStateFlow(PostLoginViewState.None) @@ -52,9 +53,9 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep private var savedResponse: LoginResponse? = null - fun startWebBrowserLogin(baseUrl: String, reAuth: Boolean = false) { + fun startWebBrowserLogin(baseUrl: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { viewModelScope.launch { - val response = repository.startLoginFlow(baseUrl, reAuth) + val response = repository.startLoginFlow(baseUrl, reAuth, accountToReauthorize) savedResponse = response if (response == null) { @@ -77,52 +78,41 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep return@launch } - val bundle = repository.parseAndLogin(loginCompletionResponse) - if (bundle == null) { - _postLoginState.value = PostLoginViewState.PostLoginRestartApp - return@launch - } - - _postLoginState.value = PostLoginViewState.PostLoginContinue(bundle) + _postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse)) } } } - fun loginWithQR(dataString: String, reAuth: Boolean = false) { + fun loginWithQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { viewModelScope.launch { - val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth) + val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth, accountToReauthorize) if (loginCompletionResponse == null) { _postLoginState.value = PostLoginViewState.PostLoginError return@launch } - val bundle = repository.parseAndLogin(loginCompletionResponse) - if (bundle == null) { - _postLoginState.value = PostLoginViewState.PostLoginRestartApp - return@launch - } - - _postLoginState.value = PostLoginViewState.PostLoginContinue(bundle) + _postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse)) } } - fun loginWithOTPQR(dataString: String, reAuth: Boolean = false) { + fun loginWithOTPQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { viewModelScope.launch { - val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth) + val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth, accountToReauthorize) if (loginCompletionResponse == null) { _postLoginState.value = PostLoginViewState.PostLoginError return@launch } - val bundle = repository.parseAndLogin(loginCompletionResponse) - if (bundle == null) { - _postLoginState.value = PostLoginViewState.PostLoginRestartApp - return@launch - } - - _postLoginState.value = PostLoginViewState.PostLoginContinue(bundle) + _postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse)) } } + private fun postLoginStateFor(result: LoginRepository.LoginResult): PostLoginViewState = + when (result) { + is LoginRepository.LoginResult.NewAccount -> PostLoginViewState.PostLoginContinue(result.bundle) + LoginRepository.LoginResult.ExistingAccount -> PostLoginViewState.PostLoginRestartApp + LoginRepository.LoginResult.DifferentAccount -> PostLoginViewState.PostLoginDifferentAccount + } + fun cancelLogin() = repository.cancelLoginFlow() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 8070c1821ca..000b730e24a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1392,6 +1392,7 @@ class ConversationsListActivity : BaseActivity() { val bundle = Bundle() bundle.putString(BundleKeys.KEY_BASE_URL, currentUser.baseUrl!!) bundle.putBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, true) + bundle.putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) intent.putExtras(bundle) startActivity(intent) } diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index d47f81a5559..1497c6d4d23 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -96,7 +96,10 @@ class UserManager internal constructor(private val userRepository: UsersReposito suspend fun getUserWithInternalId(id: Long): User? = userRepository.getUserWithIdNotScheduledForDeletion(id) suspend fun checkIfUserExists(username: String, server: String): Boolean = - userRepository.getUserWithUsernameAndServer(username, server) != null + getUserWithUsernameAndServer(username, server) != null + + suspend fun getUserWithUsernameAndServer(username: String, server: String): User? = + userRepository.getUserWithUsernameAndServer(username, server) /** * Don't ask diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44829e5b7bd..8ab569cb88a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ How to translate with transifex: Set Dismiss Sorry, something went wrong! + You logged in with a different account. Log in with the account you want to reauthorize. Create Unknown Disabled diff --git a/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt b/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt index 70bb649bef9..9bd29db7d1c 100644 --- a/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.login.data -import android.os.Bundle import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.LiveData import androidx.work.WorkInfo @@ -30,6 +29,7 @@ import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -402,7 +402,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNull(result) + assertEquals(LoginRepository.LoginResult.ExistingAccount, result) verify(localLoginDataSource).startAccountRemovalWorker() } @@ -420,8 +420,8 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNull(result) - verify(localLoginDataSource, never()).updateUser(any()) + assertEquals(LoginRepository.LoginResult.ExistingAccount, result) + verify(localLoginDataSource, never()).updateUser(any(), anyOrNull()) } @Test @@ -436,13 +436,15 @@ class LoginRepositoryTest { .thenReturn(false) whenever(localLoginDataSource.checkIfUserExists(loginData)) .thenReturn(true) + whenever(localLoginDataSource.updateUser(loginData, null)) + .thenReturn(true) // Act val result = repo.parseAndLogin(loginData) // Assert - assertNull(result) - verify(localLoginDataSource).updateUser(loginData) + assertEquals(LoginRepository.LoginResult.ExistingAccount, result) + verify(localLoginDataSource).updateUser(loginData, null) } @Test @@ -459,8 +461,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNotNull(result) - assertTrue(result is Bundle) + assertTrue(result is LoginRepository.LoginResult.NewAccount) } @Test @@ -477,8 +478,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNotNull(result) - assertTrue(result is Bundle) + assertTrue(result is LoginRepository.LoginResult.NewAccount) } @Test @@ -495,8 +495,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNotNull(result) - assertTrue(result is Bundle) + assertTrue(result is LoginRepository.LoginResult.NewAccount) } // ========== LocalLoginDataSource Integration Tests ========== @@ -534,7 +533,7 @@ class LoginRepositoryTest { // Assert verify(localLoginDataSource).checkIfUserExists(loginData) - verify(localLoginDataSource, never()).updateUser(any()) + verify(localLoginDataSource, never()).updateUser(any(), anyOrNull()) } @Test @@ -549,12 +548,37 @@ class LoginRepositoryTest { .thenReturn(false) whenever(localLoginDataSource.checkIfUserExists(loginData)) .thenReturn(true) + whenever(localLoginDataSource.updateUser(loginData, null)) + .thenReturn(true) // Act repo.parseAndLogin(loginData) // Assert - verify(localLoginDataSource).updateUser(loginData) + verify(localLoginDataSource).updateUser(loginData, null) + } + + @Test + fun `parseAndLogin passes the account to reauthorize and reports a different account`() = + runTest { + // Arrange - reauthorize the account with the internal id 7 + val qrData = "nc://login/server:https%3A//example.com" + repo.startLoginFlowFromQR(qrData, reAuth = true, accountToReauthorize = 7L) + + val loginData = LoginCompletion(200, "https://server.com", "otheruser", "apppass123") + whenever(localLoginDataSource.checkIfUserIsScheduledForDeletion(loginData)) + .thenReturn(false) + whenever(localLoginDataSource.checkIfUserExists(loginData)) + .thenReturn(true) + whenever(localLoginDataSource.updateUser(loginData, 7L)) + .thenReturn(false) + + // Act + val result = repo.parseAndLogin(loginData) + + // Assert + assertEquals(LoginRepository.LoginResult.DifferentAccount, result) + verify(localLoginDataSource).updateUser(loginData, 7L) } // ========== Edge Cases and Error Handling ========== From 2323cee736f491043670a1f156a50ab99fadef89 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 11:12:26 +0200 Subject: [PATCH 60/76] fix(call): match the current room by token and account The app-wide current room was compared by room token only. Accounts on the same server share the tokens of their common conversations, so a chat of one account could take over the call session of another account in the same conversation, the conversation list didn't restrict opening it during that call, and a call intent for the other account was treated as the running call. The current room is now matched by token and account. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/activities/CallActivity.kt | 8 +++++--- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 6 ++++-- .../talk/conversationlist/ConversationsListActivity.kt | 2 +- .../singletons/ApplicationWideCurrentRoomHolder.java | 8 ++++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index a295911ac0a..490eb4a5db6 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -771,16 +771,18 @@ class CallActivity : CallBaseActivity() { return } val newRoomToken = intent.getStringExtra(KEY_ROOM_TOKEN) - Log.d(TAG, "onNewIntent: newRoomToken=$newRoomToken roomToken=$roomToken") + // Accounts on the same server share the tokens of their common conversations, so compare the account too. + val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, conversationUser.id!!) + Log.d(TAG, "onNewIntent: newRoomToken=$newRoomToken roomToken=$roomToken newUserId=$newUserId") when { // notification tap without extras: just bring the current call back to the front newRoomToken.isNullOrEmpty() -> Unit // re-entry for the call this instance is already handling (singleTask reuse) - newRoomToken == roomToken -> setIntent(intent) + newRoomToken == roomToken && newUserId == conversationUser.id -> setIntent(intent) - // a call for another room was requested while this instance lingered in the background: + // a call for another room or account was requested while this instance lingered in the background: // end the current call and restart cleanly in onDestroy, so no stale state is reused else -> { Log.d(TAG, "onNewIntent: call requested for another room, ending current call first") diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 8594c11831a..54749edcb4b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3071,8 +3071,10 @@ class ChatActivity : private fun joinRoomWithPassword() { // if ApplicationWideCurrentRoomHolder contains a session (because a call is active), then keep the sessionId - if (ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken == - currentConversation!!.token + if (ApplicationWideCurrentRoomHolder.getInstance().isCurrentRoom( + currentConversation!!.token, + conversationUser.id + ) ) { sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 000b730e24a..d84a0a9b150 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1163,7 +1163,7 @@ class ConversationsListActivity : BaseActivity() { private fun openConversation(textToPaste: String? = "") { if (CallActivity.active && - selectedConversation!!.token != ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken + !ApplicationWideCurrentRoomHolder.getInstance().isCurrentRoom(selectedConversation!!.token, currentUser.id) ) { showSnackbar(context.getString(R.string.restrict_join_other_room_while_call)) return diff --git a/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java b/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java index 515bae06336..2bb5895af9f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java +++ b/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java @@ -41,6 +41,14 @@ public String getCurrentRoomToken() { return currentRoomToken; } + /** + * Whether the current room is the room with the given token of the account with the given internal id. The token + * alone is not enough: accounts on the same server share the tokens of their common conversations. + */ + public boolean isCurrentRoom(String roomToken, Long userId) { + return currentRoomToken.equals(roomToken) && userInRoom.getId() != null && userInRoom.getId().equals(userId); + } + public void setCurrentRoomToken(String currentRoomToken) { this.currentRoomToken = currentRoomToken; } From 84b88a3038abee27bf6a5b89d193f4110e3ded86 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 11:23:34 +0200 Subject: [PATCH 61/76] fix(share): bind the share-to account chooser to the account of the list The share-to account chooser showed the default account as the current one and listed the other accounts by the "current" flag of the database, although it belongs to a conversation list bound to its own account. Right after the list was opened for another account, it could show the wrong current account, list the list's own account as another one or leave one out. It was also themed with the default account. The chooser now gets the account of the list, lists all other accounts and uses the theme of the list. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../ConversationsListActivity.kt | 2 +- .../talk/dagger/modules/ViewModelModule.kt | 6 ------ .../ChooseAccountShareToDialogFragment.kt | 20 +++++++++++++++--- .../ChooseAccountShareToViewModel.kt | 21 ++++++++++++------- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index d84a0a9b150..645dcc9c251 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -349,7 +349,7 @@ class ConversationsListActivity : BaseActivity() { }, onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, onAccountChooserClick = { - ChooseAccountShareToDialogFragment.newInstance() + ChooseAccountShareToDialogFragment.newInstance(currentUser) .show(supportFragmentManager, ChooseAccountShareToDialogFragment.TAG) }, onNewConversation = { showNewConversationsScreen() }, diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 408033c151f..6e0c542db76 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -20,7 +20,6 @@ import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditVie import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel -import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToViewModel import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel @@ -150,11 +149,6 @@ abstract class ViewModelModule { @ViewModelKey(CallViewModel::class) abstract fun callViewModel(viewModel: CallViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ChooseAccountShareToViewModel::class) - abstract fun chooseAccountShareToViewModel(viewModel: ChooseAccountShareToViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(FileAttachmentPreviewViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt index 3b6a9a0fac3..0f17072a9d5 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider @@ -26,10 +27,12 @@ import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.net.CookieManager @@ -39,7 +42,7 @@ import javax.inject.Inject class ChooseAccountShareToDialogFragment : DialogFragment() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ChooseAccountShareToViewModel.Factory @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -63,7 +66,13 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { super.onViewCreated(view, savedInstanceState) NextcloudTalkApplication.Companion.sharedApplication!!.componentApplication.inject(this) - viewModel = ViewModelProvider(this, viewModelFactory)[ChooseAccountShareToViewModel::class.java] + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) + // The account of the conversation list this chooser belongs to, not the default account. + val user = BundleCompat.getParcelable(requireArguments(), KEY_USER, User::class.java)!! + viewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(ChooseAccountShareToViewModel::class.java) { viewModelFactory.build(user) } + )[ChooseAccountShareToViewModel::class.java] val otherUsers = mutableStateOf>(emptyList()) @@ -111,6 +120,11 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { companion object { const val TAG = "ChooseAccountShareToDialogFragment" - fun newInstance(): ChooseAccountShareToDialogFragment = ChooseAccountShareToDialogFragment() + private const val KEY_USER = "KEY_USER" + + fun newInstance(user: User): ChooseAccountShareToDialogFragment = + ChooseAccountShareToDialogFragment().apply { + arguments = Bundle().apply { putParcelable(KEY_USER, user) } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt index 2c20d7a7266..fadc244d7fc 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt @@ -16,21 +16,23 @@ import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersStartStateChooseAccoun import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserErrorStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserSuccessStateChooseAccountShareTo -import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -class ChooseAccountShareToViewModel @Inject constructor( +/** + * Lets the conversation list of [currentUser] continue a pending share with another account. + */ +class ChooseAccountShareToViewModel @AssistedInject constructor( private val userManager: UserManager, - defaultAccountProvider: DefaultAccountProvider + @Assisted val currentUser: User ) : ViewModel() { - val currentUser: User? = defaultAccountProvider.getDefaultUserBlocking() - private val _chooseAccountShareToViewState: MutableStateFlow = MutableStateFlow(LoadUsersStartStateChooseAccountShareTo) val chooseAccountShareToViewState: StateFlow = @@ -43,7 +45,7 @@ class ChooseAccountShareToViewModel @Inject constructor( try { val users = userManager.getUsers() _chooseAccountShareToViewState.value = - LoadUsersSuccessStateChooseAccountShareTo(users.filter { !it.current }) + LoadUsersSuccessStateChooseAccountShareTo(users.filter { it.id != currentUser.id }) } catch (e: Exception) { Log.e(TAG, "Error loading users", e) _chooseAccountShareToViewState.value = LoadUsersSuccessStateChooseAccountShareTo(emptyList()) @@ -69,6 +71,11 @@ class ChooseAccountShareToViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): ChooseAccountShareToViewModel + } + companion object { private val TAG = ChooseAccountShareToViewModel::class.simpleName } From 7d710788e2fa141d5227b591f075248787e0560c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 11:55:46 +0200 Subject: [PATCH 62/76] fix(remotefilebrowser): report the file browser started without its account RemoteFileBrowserActivity is not a BaseActivity and silently fell back to the default account when started without KEY_INTERNAL_USER_ID. Like all other screens, it now fails in debug builds and logs an error in release builds in that case. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../activities/RemoteFileBrowserActivity.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index b1ab33ecf44..c920370efa9 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -26,6 +26,7 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import autodagger.AutoInjector +import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.dagger.modules.assistedViewModels @@ -85,11 +86,17 @@ class RemoteFileBrowserActivity : super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + // Not a BaseActivity, so it applies the same rule itself: it must be started with the id of its account. val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + if (userId == 0L) { + val message = "${javaClass.simpleName} was started without ${BundleKeys.KEY_INTERNAL_USER_ID}" + check(!BuildConfig.DEBUG) { message } + Log.e(TAG, "$message, using the default account") + } user = runBlocking { if (userId != 0L) userManager.getUserWithId(userId) else userManager.getDefaultUser() } ?: run { - Log.e(TAG, "No user found") + Log.e(TAG, "No user found for id $userId") finish() return } From 9f6c9acdf6d102b944c677a4d0b003e705d932b2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 12:00:25 +0200 Subject: [PATCH 63/76] fix(remotefilebrowser): make the file browser a BaseActivity RemoteFileBrowserActivity extended AppCompatActivity, so it resolved its account itself and missed what BaseActivity provides: the screen lock check, FLAG_SECURE for screen security, the incognito keyboard, the edge-to-edge handling, and applying the account theme before restored fragments like the sorting dialog. It now extends BaseActivity and sets up its account with setUpBoundUserOrFinish() like the other screens. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../activities/RemoteFileBrowserActivity.kt | 55 +------------------ 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index c920370efa9..0d4a4612783 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -10,24 +10,20 @@ package com.nextcloud.talk.remotefilebrowser.activities import android.app.Activity import android.content.Intent -import android.os.Build import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import autodagger.AutoInjector -import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R +import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.user.model.User @@ -36,37 +32,23 @@ import com.nextcloud.talk.remotefilebrowser.SelectionInterface import com.nextcloud.talk.remotefilebrowser.adapters.RemoteFileBrowserItemsAdapter import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.ui.dialog.SortingOrderDialogFragment -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileSortOrder -import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MIME_TYPE_FILTER -import kotlinx.coroutines.runBlocking import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class RemoteFileBrowserActivity : - AppCompatActivity(), + BaseActivity(), SelectionInterface, SwipeRefreshLayout.OnRefreshListener { @Inject lateinit var viewModelFactory: RemoteFileBrowserItemsViewModel.Factory - @Inject - lateinit var userManager: UserManager - private lateinit var user: User - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - @Inject - lateinit var viewThemeUtilsFactory: ViewThemeUtilsFactory - @Inject lateinit var dateUtils: DateUtils @@ -85,23 +67,7 @@ class RemoteFileBrowserActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - // Not a BaseActivity, so it applies the same rule itself: it must be started with the id of its account. - val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) - if (userId == 0L) { - val message = "${javaClass.simpleName} was started without ${BundleKeys.KEY_INTERNAL_USER_ID}" - check(!BuildConfig.DEBUG) { message } - Log.e(TAG, "$message, using the default account") - } - user = runBlocking { - if (userId != 0L) userManager.getUserWithId(userId) else userManager.getDefaultUser() - } ?: run { - Log.e(TAG, "No user found for id $userId") - finish() - return - } - - viewThemeUtils = viewThemeUtilsFactory.forUser(user) + user = setUpBoundUserOrFinish() ?: return binding = ActivityRemoteFileBrowserBinding.inflate(layoutInflater) setSupportActionBar(binding.remoteFileBrowserItemsToolbar) @@ -271,21 +237,6 @@ class RemoteFileBrowserActivity : binding.recyclerView.visibility = View.VISIBLE } - fun initSystemBars() { - val decorView = window.decorView - decorView.setOnApplyWindowInsetsListener { view, insets -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - val systemBars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() or - WindowInsetsCompat.Type.displayCutout() - ) - view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - } - insets - } - ViewCompat.requestApplyInsets(decorView) - } - override fun onRefresh() { refreshCurrentPath() } From a428c33f2aee535bb63c1d4eb10d19fb46aec6c6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 12:09:36 +0200 Subject: [PATCH 64/76] fix(fullscreenfile): bind the text viewer to the account of the chat FullScreenTextViewerActivity extended AppCompatActivity and got the user name and server as separate extras, so it used the colors of the default account and missed the screen lock check, FLAG_SECURE and the incognito keyboard of BaseActivity. It now extends BaseActivity and gets the account of the chat. The sorting order dialog now also uses the theme of the activity it is shown in instead of the default account's. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../FullScreenTextViewerActivity.kt | 16 +++++----------- .../ui/dialog/SortingOrderDialogFragment.java | 2 ++ .../com/nextcloud/talk/utils/FileViewerUtils.kt | 3 +-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt index 8a4d9e432e6..7f3e118e042 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt @@ -13,7 +13,6 @@ import android.content.Intent import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.MaterialTheme import androidx.core.content.FileProvider import androidx.core.net.toUri @@ -21,36 +20,33 @@ import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R +import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.TEXT_PREFIX_GENERIC -import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID import java.io.File -import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class FullScreenTextViewerActivity : AppCompatActivity() { +class FullScreenTextViewerActivity : BaseActivity() { - @Inject - lateinit var viewThemeUtils: ViewThemeUtils private lateinit var textFile: File override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val user = setUpBoundUserOrFinish() ?: return val fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isMarkdown = intent.getBooleanExtra("IS_MARKDOWN", false) val fileId = intent.getStringExtra("FILE_ID").orEmpty() val link = intent.getStringExtra("LINK") - val username = intent.getStringExtra("USERNAME").orEmpty() - val baseUrl = intent.getStringExtra("BASE_URL").orEmpty() + val username = user.username.orEmpty() + val baseUrl = user.baseUrl.orEmpty() textFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { Log.e(TAG, "Invalid text filename: $fileName") finish() @@ -58,8 +54,6 @@ class FullScreenTextViewerActivity : AppCompatActivity() { } val text = readFile(textFile) - adjustUIForAPILevel35() - setContent { val colorScheme = viewThemeUtils.getColorScheme(this) MaterialTheme(colorScheme = colorScheme) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java b/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java index c23a60a880f..ac7f380d703 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java @@ -21,6 +21,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.databinding.SortingOrderFragmentBinding; +import com.nextcloud.talk.ui.theme.HostViewThemeUtils; import com.nextcloud.talk.ui.theme.ViewThemeUtils; import com.nextcloud.talk.utils.FileSortOrder; import com.nextcloud.talk.utils.preferences.AppPreferences; @@ -98,6 +99,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); + viewThemeUtils = HostViewThemeUtils.hostViewThemeUtils(getActivity(), viewThemeUtils); setupDialogElements(); setupListeners(); diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 87c79782c26..f820cdcce59 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -270,8 +270,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { fullScreenTextViewerIntent.putExtra("IS_MARKDOWN", isMarkdown(mimetype)) fullScreenTextViewerIntent.putExtra("FILE_ID", fileId) fullScreenTextViewerIntent.putExtra("LINK", link) - fullScreenTextViewerIntent.putExtra("USERNAME", user.username) - fullScreenTextViewerIntent.putExtra("BASE_URL", user.baseUrl) + fullScreenTextViewerIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) context.startActivity(fullScreenTextViewerIntent) } From 4e1d13002e506e8fa1b1cd6dbff082b502c48389 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 13:00:44 +0200 Subject: [PATCH 65/76] fix(login): don't start a second login when the login screen is recreated The view model of BrowserLoginActivity survives configuration changes, but onCreate() starts the login on every recreation. The second login request replaced the pending one, so the app polled a login session the browser was not authorizing. The view model now starts a login only once, so a recreated activity continues the pending login. After process death, the login is started again. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../BrowserLoginActivityViewModel.kt | 14 ++++ .../BrowserLoginActivityViewModelTest.kt | 74 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt b/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt index 1100f2a1b96..481719fe076 100644 --- a/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt @@ -53,7 +53,19 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep private var savedResponse: LoginResponse? = null + // The view model survives a recreation of the activity, which starts the login again. A started login is + // continued instead of starting a second one, whose session the browser would not authorize. After process + // death the view model is new, so the login is started again. + private var isLoginStarted = false + + private fun startLoginOnce(): Boolean { + if (isLoginStarted) return false + isLoginStarted = true + return true + } + fun startWebBrowserLogin(baseUrl: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { + if (!startLoginOnce()) return viewModelScope.launch { val response = repository.startLoginFlow(baseUrl, reAuth, accountToReauthorize) savedResponse = response @@ -84,6 +96,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep } fun loginWithQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { + if (!startLoginOnce()) return viewModelScope.launch { val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth, accountToReauthorize) if (loginCompletionResponse == null) { @@ -96,6 +109,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep } fun loginWithOTPQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { + if (!startLoginOnce()) return viewModelScope.launch { val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth, accountToReauthorize) if (loginCompletionResponse == null) { diff --git a/app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt new file mode 100644 index 00000000000..0d089501bd0 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.account.viewmodels + +import com.nextcloud.talk.account.data.LoginRepository +import com.nextcloud.talk.account.data.model.LoginResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +@OptIn(ExperimentalCoroutinesApi::class) +class BrowserLoginActivityViewModelTest { + + private val repository: LoginRepository = mock() + private lateinit var viewModel: BrowserLoginActivityViewModel + + private val loginResponse = LoginResponse(token = "token", pollUrl = "https://example.com/poll", loginUrl = URL) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + viewModel = BrowserLoginActivityViewModel(repository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `starting the login again continues the started login`() { + wheneverBlocking { repository.startLoginFlow(any(), any(), anyOrNull()) }.thenReturn(loginResponse) + + viewModel.startWebBrowserLogin(BASE_URL) + viewModel.startWebBrowserLogin(BASE_URL) + + verifyBlocking(repository, times(1)) { startLoginFlow(any(), any(), anyOrNull()) } + assertEquals( + BrowserLoginActivityViewModel.InitialLoginViewState.InitialLoginRequestSuccess(URL), + viewModel.initialLoginRequestState.value + ) + } + + @Test + fun `a QR code login is not started twice either`() { + wheneverBlocking { repository.startOTPLoginFlow(any(), any(), anyOrNull()) }.thenReturn(null) + + viewModel.loginWithOTPQR("qr") + viewModel.loginWithOTPQR("qr") + + verifyBlocking(repository, times(1)) { startOTPLoginFlow(any(), any(), anyOrNull()) } + } + + companion object { + private const val BASE_URL = "https://example.com" + private const val URL = "https://example.com/login" + } +} From 9a3d6fc579f7b8df70846b2745be140d74ac0089 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 13:22:35 +0200 Subject: [PATCH 66/76] fix(login): don't add an account that logs in during a reauthorization When another account than the one to reauthorize logged in and it was not set up in the app yet, the reauthorization started the account verification and added it as a new account, leaving the account to reauthorize unauthorized. During a reauthorization, an account that is not set up is now reported as a different account as well. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/account/data/LoginRepository.kt | 10 ++++++--- .../talk/login/data/LoginRepositoryTest.kt | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt b/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt index 82f9986f46a..c9a3bf05d66 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt @@ -203,9 +203,9 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin } /** - * Returns [LoginResult.NewAccount] if the account is not scheduled for deletion and doesn't exist yet. For an - * existing account being reauthorized, [LoginResult.DifferentAccount] if another account than the one to - * reauthorize logged in, which is then left unchanged. + * Returns [LoginResult.NewAccount] if the account is not scheduled for deletion and doesn't exist yet. During a + * reauthorization, [LoginResult.DifferentAccount] if another account than the one to reauthorize logged in, set + * up or not, which is then left unchanged. */ suspend fun parseAndLogin(loginData: LoginCompletion): LoginResult { if (local.checkIfUserIsScheduledForDeletion(loginData)) { @@ -223,6 +223,10 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin } return LoginResult.ExistingAccount + } else if (shouldReauthorizeUser) { + // A reauthorization must not add the account that logged in instead. + Log.w(TAG, "Logged in with an account that is not set up during a reauthorization. Skipped user creation.") + return LoginResult.DifferentAccount } else { return LoginResult.NewAccount(startAccountVerification(loginData)) } diff --git a/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt b/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt index 9bd29db7d1c..1e74b307402 100644 --- a/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt @@ -498,6 +498,27 @@ class LoginRepositoryTest { assertTrue(result is LoginRepository.LoginResult.NewAccount) } + @Test + fun `parseAndLogin reports a different account for an account that is not set up during a reauthorization`() = + runTest { + // Arrange - reauthorize the account with the internal id 7 + val qrData = "nc://login/server:https%3A//example.com" + repo.startLoginFlowFromQR(qrData, reAuth = true, accountToReauthorize = 7L) + + val loginData = LoginCompletion(200, "https://server.com", "newuser", "apppass123") + whenever(localLoginDataSource.checkIfUserIsScheduledForDeletion(loginData)) + .thenReturn(false) + whenever(localLoginDataSource.checkIfUserExists(loginData)) + .thenReturn(false) + + // Act + val result = repo.parseAndLogin(loginData) + + // Assert + assertEquals(LoginRepository.LoginResult.DifferentAccount, result) + verify(localLoginDataSource, never()).updateUser(any(), anyOrNull()) + } + // ========== LocalLoginDataSource Integration Tests ========== @Test From 995b92af72e71b6da06ca128531b3a83ecef537f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 13:45:07 +0200 Subject: [PATCH 67/76] fix(user): don't authenticate requests of one account with another's session cookie All accounts share one HTTP client with one cookie store, which keeps cookies per server only. A session cookie set by a request of one account was sent along with the requests of another account on the same server, and the server treated them as the first account: e.g. marcel2's conversation list showed marcel's conversations and its own conversations returned 404. Clearing the cookies on account switches didn't help once several accounts send requests at the same time. Requests with an Authorization header now neither send nor store cookies, so they are authenticated by their own credentials only. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/dagger/modules/RestModule.java | 3 + .../utils/CredentialsCookieInterceptor.kt | 39 ++++++++ .../utils/CredentialsCookieInterceptorTest.kt | 90 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java b/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java index c98fd2e82ff..c4c0e362558 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java @@ -17,6 +17,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.users.UserManager; import com.nextcloud.talk.utils.ApiUtils; +import com.nextcloud.talk.utils.CredentialsCookieInterceptor; import com.nextcloud.talk.utils.RemoteWipeInterceptor; import com.nextcloud.talk.utils.LoggingUtils; import com.nextcloud.talk.utils.preferences.AppPreferences; @@ -223,6 +224,8 @@ OkHttpClient provideHttpClient(Proxy proxy, AppPreferences appPreferences, httpClient.addInterceptor(new HeadersInterceptor()); httpClient.addInterceptor(new RemoteWipeInterceptor(userManager, context, sslSocketFactoryCompat, trustManager)); httpClient.addInterceptor(loggingHttpInterceptor); + // Session cookies of one account must not authenticate the requests of another one on the same server. + httpClient.addNetworkInterceptor(new CredentialsCookieInterceptor()); return httpClient.build(); } diff --git a/app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt new file mode 100644 index 00000000000..4502ff4770d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Keeps server session cookies out of requests that authenticate with their own credentials. + * + * All accounts share one HTTP client with one cookie store, which keeps cookies per server only. A session cookie of + * one account would be sent along with the requests of another account on the same server, and the server would + * treat those requests as the first account. Requests with an Authorization header therefore neither send nor store + * cookies, so they are authenticated by their credentials alone. + * + * Must be added as a network interceptor, as OkHttp adds the cookies of the cookie jar and stores received cookies + * around the network interceptors. + */ +class CredentialsCookieInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (request.header(AUTHORIZATION) == null) { + return chain.proceed(request) + } + val response = chain.proceed(request.newBuilder().removeHeader(COOKIE).build()) + return response.newBuilder().removeHeader(SET_COOKIE).build() + } + + private companion object { + const val AUTHORIZATION = "Authorization" + const val COOKIE = "Cookie" + const val SET_COOKIE = "Set-Cookie" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt new file mode 100644 index 00000000000..aa1e79e34e5 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.net.CookieManager + +class CredentialsCookieInterceptorTest { + + private val server = MockWebServer() + private val cookieManager = CookieManager() + private lateinit var client: OkHttpClient + + @Before + fun setUp() { + server.start() + client = OkHttpClient.Builder() + .cookieJar(JavaNetCookieJar(cookieManager)) + .addNetworkInterceptor(CredentialsCookieInterceptor()) + .build() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `a request with credentials doesn't send a stored session cookie`() { + server.enqueue(MockResponse().addHeader("Set-Cookie", "$SESSION_COOKIE; Path=/")) + server.enqueue(MockResponse()) + + // An anonymous request stores the session cookie. + execute(authorization = null) + assertEquals(1, cookieManager.cookieStore.cookies.size) + + execute(authorization = CREDENTIALS) + + server.takeRequest() + assertNull(server.takeRequest().getHeader("Cookie")) + } + + @Test + fun `a response to a request with credentials doesn't store its session cookie`() { + server.enqueue(MockResponse().addHeader("Set-Cookie", "$SESSION_COOKIE; Path=/")) + + execute(authorization = CREDENTIALS) + + assertTrue(cookieManager.cookieStore.cookies.isEmpty()) + } + + @Test + fun `a request without credentials keeps using cookies`() { + server.enqueue(MockResponse().addHeader("Set-Cookie", "$SESSION_COOKIE; Path=/")) + server.enqueue(MockResponse()) + + execute(authorization = null) + execute(authorization = null) + + server.takeRequest() + assertEquals(SESSION_COOKIE, server.takeRequest().getHeader("Cookie")) + } + + private fun execute(authorization: String?) { + val request = Request.Builder() + .url(server.url("/ocs/v2.php/apps/spreed/api/v4/room")) + .apply { authorization?.let { header("Authorization", it) } } + .build() + client.newCall(request).execute().close() + } + + companion object { + private const val SESSION_COOKIE = "nc_session_id=abc" + private const val CREDENTIALS = "Basic bWFyY2VsMjp0b2tlbg==" + } +} From 7e2f079798c06a7d8f387ea5bfa4e127bd8535ba Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 13:50:11 +0200 Subject: [PATCH 68/76] fix(login): report a reauthorization that stored nothing as failed LocalLoginDataSource.updateUser() reported success even if no row was updated, e.g. when the account was removed between looking it up and storing the new credentials. It now only reports success if the credentials were stored. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/account/data/io/LocalLoginDataSource.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt index bb85507086c..395537520f2 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt @@ -25,14 +25,18 @@ class LocalLoginDataSource(val userManager: UserManager, val appPreferences: App /** * Stores the new credentials of a reauthorized account. That is the account that logged in, found by its login * name and server, as the browser login flow grants an app password for whoever is logged in there. Returns false - * without any change if it is not the account with the internal id [accountToReauthorize]. + * without any change if it is not the account with the internal id [accountToReauthorize], or if nothing was + * stored, e.g. because the account was removed meanwhile. */ suspend fun updateUser(loginData: LoginCompletion, accountToReauthorize: Long?): Boolean { val user = userManager.getUserWithUsernameAndServer(loginData.loginName, loginData.server) ?.takeIf { accountToReauthorize == null || it.id == accountToReauthorize } ?: return false - userManager.updateCredentials(user.id!!, loginData.appPassword, appPreferences.temporaryClientCertAlias) - return true + return userManager.updateCredentials( + user.id!!, + loginData.appPassword, + appPreferences.temporaryClientCertAlias + ) > 0 } fun startAccountRemovalWorker(): LiveData { From abbe0992253a29b558e839ce585afd4d65d3a00f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 13:53:03 +0200 Subject: [PATCH 69/76] fix(contacts): open a created conversation only while the screen is visible The contacts screen collected the created room state as long as the activity existed, so it could start the chat while in the background. It now collects it only while started; a conversation created meanwhile is opened when the screen is started again. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/contacts/ContactsActivity.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index f2bb4b20bb3..30100da77a0 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -15,7 +15,9 @@ import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication @@ -84,14 +86,17 @@ class ContactsActivity : BaseActivity() { private fun observeCreatedRoom() { lifecycleScope.launch { - contactsViewModel.roomViewState.collect { state -> - if (state is ContactsViewModel.RoomUiState.Success) { - state.conversation?.token?.let { token -> - val chatIntent = ChatActivity.createIntent(this@ContactsActivity, state.userId, token) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) + // Only while visible: a conversation created meanwhile is still opened when the screen is started again. + repeatOnLifecycle(Lifecycle.State.STARTED) { + contactsViewModel.roomViewState.collect { state -> + if (state is ContactsViewModel.RoomUiState.Success) { + state.conversation?.token?.let { token -> + val chatIntent = ChatActivity.createIntent(this@ContactsActivity, state.userId, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } + contactsViewModel.clearRoomState() } - contactsViewModel.clearRoomState() } } } From 30e8d4539f1965e45e7a4782721053e3cd3c6b46 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 15:44:56 +0200 Subject: [PATCH 70/76] fix(user): only remove the account whose credentials were rejected RemoteWipeInterceptor removed the first account on the server of any request answered with 401, even if the request carried no credentials or those of another account, and even if the server hadn't requested a wipe. A 401 for an image loaded without credentials therefore removed an account whose credentials were still valid, and with several accounts on the same server it could remove the wrong one. It now only reacts to a 401 for a request with credentials, and resolves the account by exactly those credentials. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/utils/RemoteWipeInterceptor.kt | 25 +++++- .../talk/utils/RemoteWipeInterceptorTest.kt | 85 +++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt index 68b273268eb..8446a68ce34 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt @@ -60,7 +60,7 @@ class RemoteWipeInterceptor( return response } - val candidate = resolveWipeCandidate(chain.request().url.toString()) ?: return response + val candidate = resolveWipeCandidate(chain.request()) ?: return response if (!handledUserIds.add(candidate.userId)) { Log.d(TAG, "User ${candidate.userId} was already handled, ignoring") return response @@ -72,11 +72,27 @@ class RemoteWipeInterceptor( return response } - private fun resolveWipeCandidate(requestUrl: String): WipeCandidate? { + /** + * The account whose credentials the server rejected: the one with exactly the credentials of [request], on the + * server of its URL. A 401 for a request without credentials, or with credentials of no stored account, says + * nothing about an account, e.g. a request that relied on a session cookie, and is ignored. Several accounts can + * be on the same server, so the server alone does not identify the account. + */ + private fun resolveWipeCandidate(request: Request): WipeCandidate? { + val requestUrl = request.url.toString() + val authorization = request.header(AUTHORIZATION) + if (authorization == null) { + Log.d(TAG, "401 for a request without credentials, ignoring: $requestUrl") + return null + } val user = runBlocking { userManager.getUsers() } - .firstOrNull { it.baseUrl != null && requestUrl.startsWith(it.baseUrl!!) } + .firstOrNull { + it.baseUrl != null && + requestUrl.startsWith(it.baseUrl!!) && + ApiUtils.getCredentials(it.username, it.token) == authorization + } if (user == null) { - Log.d(TAG, "No known user matches base URL of $requestUrl, ignoring") + Log.d(TAG, "No known user matches the credentials and base URL of $requestUrl, ignoring") return null } @@ -155,6 +171,7 @@ class RemoteWipeInterceptor( companion object { private const val TAG = "RemoteWipeInterceptor" private const val HTTP_UNAUTHORIZED = 401 + private const val AUTHORIZATION = "Authorization" private const val WIPE_PATH = "wipe" } } diff --git a/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt new file mode 100644 index 00000000000..9ef533203dc --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.Context +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ssl.SSLSocketFactoryCompat +import com.nextcloud.talk.utils.ssl.TrustManager +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +/** + * A 401 must only remove the account whose credentials the request carried. These cases must never remove one. + */ +class RemoteWipeInterceptorTest { + + private val userManager: UserManager = mock() + private val interceptor = RemoteWipeInterceptor( + userManager, + mock(), + mock(), + mock() + ) + + private val marcel = User(id = 1, username = "marcel", token = "tokenA", baseUrl = BASE_URL) + private val marcel2 = User(id = 2, username = "marcel2", token = "tokenB", baseUrl = BASE_URL) + + @Test + fun `a 401 for a request without credentials removes no account`() { + wheneverBlocking { userManager.getUsers() } doReturn listOf(marcel, marcel2) + + val response = interceptor.intercept(chainAnswering401(Request.Builder().url(PREVIEW_URL).build())) + + assertEquals(HTTP_UNAUTHORIZED, response.code) + verifyBlocking(userManager, never()) { scheduleUserForDeletionWithId(any()) } + } + + @Test + fun `a 401 for credentials of no stored account removes no account`() { + wheneverBlocking { userManager.getUsers() } doReturn listOf(marcel, marcel2) + val request = Request.Builder() + .url(PREVIEW_URL) + .header("Authorization", Credentials.basic("marcel2", "outdatedToken")) + .build() + + interceptor.intercept(chainAnswering401(request)) + + verifyBlocking(userManager, never()) { scheduleUserForDeletionWithId(any()) } + } + + private fun chainAnswering401(request: Request): Interceptor.Chain { + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(HTTP_UNAUTHORIZED) + .message("Unauthorized") + .build() + return mock { + on { request() } doReturn request + on { proceed(any()) } doReturn response + } + } + + companion object { + private const val BASE_URL = "https://cloud.example.com" + private const val PREVIEW_URL = "$BASE_URL/index.php/core/preview?fileId=1" + private const val HTTP_UNAUTHORIZED = 401 + } +} From b769ddddca9ad6aaa8b03a218bcfb888a6ce8fb2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 15:44:57 +0200 Subject: [PATCH 71/76] fix(chat): load images of the own server with the account's credentials Chat media previews, quote thumbnails, link previews of the own server, the media viewer and conversation avatars of open conversations were loaded without credentials. They only worked when the shared cookie store happened to hold a server session, which could belong to another account on the same server, and failed since authenticated requests no longer store session cookies. They now send the credentials of the screen's account, only for URLs of its own server. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/ui/model/ChatMessageUi.kt | 21 ++++++++++--- .../activities/MediaViewerActivity.kt | 22 ++++++++----- .../activities/MediaViewerScreen.kt | 12 +++++-- .../ListOpenConversationsActivity.kt | 31 ++++++++++++------- .../OpenConversationsScreen.kt | 3 ++ .../com/nextcloud/talk/ui/ImageAuthHeader.kt | 22 +++++++++++++ .../talk/ui/chat/ChatMessageScaffold.kt | 7 ++++- .../com/nextcloud/talk/ui/chat/LinkMessage.kt | 23 +++++++++++--- .../talk/ui/chat/MediaGroupMessage.kt | 3 +- .../nextcloud/talk/ui/chat/MediaMessage.kt | 3 +- 10 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index f475d2c762a..7a059e30b19 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -69,7 +69,11 @@ sealed interface MessageTypeContent { object RegularText : MessageTypeContent object SystemMessage : MessageTypeContent - data class LinkPreview(val url: String) : MessageTypeContent + /** + * [authHeader] authenticates images of the account's own server at [serverBaseUrl], e.g. reference previews. + */ + data class LinkPreview(val url: String, val serverBaseUrl: String? = null, val authHeader: String? = null) : + MessageTypeContent data class Media( val previewUrl: String?, @@ -79,7 +83,9 @@ sealed interface MessageTypeContent { val blurhash: String? = null, val width: Int? = null, val height: Int? = null, - val isClassified: Boolean = false + val isClassified: Boolean = false, + // Credentials of the account, as the preview is always loaded from its server. + val authHeader: String? = null ) : MessageTypeContent data class UploadingMedia( @@ -291,7 +297,13 @@ fun getMessageTypeContent(user: User, message: ChatMessage, isClassified: Boolea getDeckContent(message) } else { message.extractLinkPreviewUrl(user) - ?.let { MessageTypeContent.LinkPreview(url = it) } + ?.let { + MessageTypeContent.LinkPreview( + url = it, + serverBaseUrl = user.baseUrl, + authHeader = ApiUtils.getCredentials(user.username, user.token) + ) + } ?: MessageTypeContent.RegularText } @@ -332,7 +344,8 @@ fun getMediaContent(user: User, message: ChatMessage, isClassified: Boolean = fa blurhash = message.fileParameters.blurhash, width = message.fileParameters.width, height = message.fileParameters.height, - isClassified = isClassified + isClassified = isClassified, + authHeader = ApiUtils.getCredentials(user.username, user.token) ) } diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 6ab3d3baa78..13b8bb7b748 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -38,6 +38,9 @@ import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import java.io.File import javax.inject.Inject +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.ui.LocalImageAuthHeader +import androidx.compose.runtime.CompositionLocalProvider /** * Swipeable, group-aware media viewer - the entry point for every image/video tap in chat. See @@ -56,6 +59,7 @@ class MediaViewerActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) val user = setUpBoundUserOrFinish() ?: return + val imageAuthHeader = ApiUtils.getCredentials(user.username, user.token) val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) @@ -82,14 +86,16 @@ class MediaViewerActivity : BaseActivity() { setContent { val colorScheme = viewThemeUtils.getColorScheme(this@MediaViewerActivity) MaterialTheme(colorScheme = colorScheme) { - MediaViewerScreen( - viewModel = viewModel, - onShare = ::shareFile, - onSave = ::showSaveDialog, - onControlsVisibilityChanged = { visible -> - if (visible) exitImmersiveMode() else enterImmersiveMode() - } - ) + CompositionLocalProvider(LocalImageAuthHeader provides imageAuthHeader) { + MediaViewerScreen( + viewModel = viewModel, + onShare = ::shareFile, + onSave = ::showSaveDialog, + onControlsVisibilityChanged = { visible -> + if (visible) exitImmersiveMode() else enterImmersiveMode() + } + ) + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 60894e910af..31218c2847f 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -90,6 +90,8 @@ import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DrawableUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils +import com.nextcloud.talk.ui.LocalImageAuthHeader +import com.nextcloud.talk.ui.withAuthHeader import java.io.File import kotlinx.coroutines.launch import pl.droidsonroids.gif.GifDrawable @@ -330,7 +332,10 @@ private fun MediaPage( private fun PreviewPlaceholder(item: MediaViewerItem, onToggleControls: () -> Unit) { if (item.previewUrl != null) { AsyncImage( - model = item.previewUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(item.previewUrl) + .withAuthHeader(LocalImageAuthHeader.current) + .build(), contentDescription = item.fileName, modifier = Modifier.fillMaxSize().clickable(onClick = onToggleControls), contentScale = ContentScale.Fit @@ -512,7 +517,10 @@ private fun ThumbnailTile(item: MediaViewerItem, isSelected: Boolean, onClick: ( ) { if (item.previewUrl != null) { AsyncImage( - model = item.previewUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(item.previewUrl) + .withAuthHeader(LocalImageAuthHeader.current) + .build(), contentDescription = item.fileName, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index ec1c3888501..8b7dc48de3a 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -23,6 +23,9 @@ import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.utils.adjustUIForAPILevel35 import javax.inject.Inject +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.ui.LocalImageAuthHeader +import androidx.compose.runtime.CompositionLocalProvider @AutoInjector(NextcloudTalkApplication::class) class ListOpenConversationsActivity : BaseActivity() { @@ -51,19 +54,23 @@ class ListOpenConversationsActivity : BaseActivity() { MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - OpenConversationsScreen( - viewState = viewState, - searchTerm = searchTerm, - userBaseUrl = user.baseUrl, - listenerInput = OpenConversationsScreenListenerInput( - onSearchTermChange = { term -> - openConversationsViewModel.updateSearchTerm(term) - openConversationsViewModel.fetchConversations() - }, - onConversationClick = { conversation -> navigateToChat(user.id!!, conversation) }, - onBackClick = { onBackPressedDispatcher.onBackPressed() } + CompositionLocalProvider( + LocalImageAuthHeader provides ApiUtils.getCredentials(user.username, user.token) + ) { + OpenConversationsScreen( + viewState = viewState, + searchTerm = searchTerm, + userBaseUrl = user.baseUrl, + listenerInput = OpenConversationsScreenListenerInput( + onSearchTermChange = { term -> + openConversationsViewModel.updateSearchTerm(term) + openConversationsViewModel.fetchConversations() + }, + onConversationClick = { conversation -> navigateToChat(user.id!!, conversation) }, + onBackClick = { onBackPressedDispatcher.onBackPressed() } + ) ) - ) + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt b/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt index 94d0db1f702..480611f892e 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt @@ -90,6 +90,8 @@ import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewMode import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DisplayUtils import kotlinx.coroutines.flow.distinctUntilChanged +import com.nextcloud.talk.ui.withAuthHeader +import com.nextcloud.talk.ui.LocalImageAuthHeader private val TAG = ListOpenConversationsActivity::class.java.simpleName @@ -352,6 +354,7 @@ private fun OpenConversationItem( } val imageRequest = ImageRequest.Builder(context) .data(avatarUrl) + .withAuthHeader(LocalImageAuthHeader.current) .transformations(CircleCropTransformation()) .error(errorPlaceholder) .placeholder(errorPlaceholder) diff --git a/app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt b/app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt new file mode 100644 index 00000000000..ffcadc3c55c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import coil.request.ImageRequest + +/** + * Credentials of the account a screen belongs to, for images it loads from that account's server, like file previews + * or conversation avatars. Requests with credentials don't use session cookies, so these images need them. + */ +val LocalImageAuthHeader = staticCompositionLocalOf { null } + +/** + * Adds [authHeader] as Authorization header, if there is one. + */ +fun ImageRequest.Builder.withAuthHeader(authHeader: String?): ImageRequest.Builder = + apply { authHeader?.let { addHeader("Authorization", it) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index 0b071dcbdce..2b6584749ab 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -99,6 +99,8 @@ import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.TextMatchers import java.time.LocalDate +import coil.request.ImageRequest +import com.nextcloud.talk.ui.withAuthHeader private val regularTextSize = 16.sp private val timeTextSize = 12.sp @@ -907,7 +909,10 @@ private fun QuoteMediaRow(message: ChatMessageUi, content: MessageTypeContent.Me ) { if (content.previewUrl != null) { AsyncImage( - model = content.previewUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(content.previewUrl) + .withAuthHeader(content.authHeader) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt index c8ef6abb15a..9e72159bce6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -70,7 +70,8 @@ fun LinkMessage( LinkPreviewCard( og = og, url = og.link?.takeIf { it.isNotBlank() } ?: typeContent.url, - highlightSearchTerm = highlightSearchTerm + highlightSearchTerm = highlightSearchTerm, + typeContent = typeContent ) } } @@ -79,7 +80,12 @@ fun LinkMessage( } @Composable -private fun LinkPreviewCard(og: OpenGraphObjectDto, url: String, highlightSearchTerm: String?) { +private fun LinkPreviewCard( + og: OpenGraphObjectDto, + url: String, + highlightSearchTerm: String?, + typeContent: MessageTypeContent.LinkPreview +) { val context = LocalContext.current Surface( shape = MaterialTheme.shapes.small, @@ -97,7 +103,7 @@ private fun LinkPreviewCard(og: OpenGraphObjectDto, url: String, highlightSearch og = og, highlightSearchTerm = highlightSearchTerm ) - LinkPreviewImage(thumbUrl = og.thumb, context = context) + LinkPreviewImage(thumbUrl = og.thumb, context = context, typeContent = typeContent) } } } @@ -140,12 +146,19 @@ private fun LinkPreviewTexts(og: OpenGraphObjectDto, highlightSearchTerm: String } @Composable -private fun LinkPreviewImage(thumbUrl: String?, context: android.content.Context) { +private fun LinkPreviewImage( + thumbUrl: String?, + context: android.content.Context, + typeContent: MessageTypeContent.LinkPreview +) { thumbUrl?.takeIf { it.isNotBlank() }?.let { + // Credentials only for images of the account's own server, never for external ones. + val isOwnServer = typeContent.serverBaseUrl?.let { baseUrl -> it.startsWith(baseUrl) } == true val loadedImage = load( imageUri = it, context = context, - errorPlaceholderImage = R.drawable.ic_mimetype_image + errorPlaceholderImage = R.drawable.ic_mimetype_image, + authHeader = if (isOwnServer) typeContent.authHeader else null ) AsyncImage( model = loadedImage, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt index 5c7b571b5fd..285ba26466d 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -434,7 +434,8 @@ private fun SyncedMediaTileContent( imageUri = typeContent.previewUrl, context = context, errorPlaceholderImage = typeContent.drawableResourceId, - animated = typeContent.animateGif + animated = typeContent.animateGif, + authHeader = typeContent.authHeader ) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt index ba56c5a954d..31f1a6b742c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt @@ -266,7 +266,8 @@ fun MediaMessage( imageUri = retryAwarePreviewUrl, context = context, errorPlaceholderImage = typeContent.drawableResourceId, - animated = typeContent.animateGif + animated = typeContent.animateGif, + authHeader = typeContent.authHeader ) } } From 1b78f3e2c2f288851942f05764329d21c1ab3b05 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 15:56:31 +0200 Subject: [PATCH 72/76] fix(ssl): choose the client certificate by the server of the connection KeyManager always offered the client certificate of the default account. Since screens and background work are bound to their account, requests of other accounts are common, and they offered the wrong certificate or none, so mutual TLS failed for them. The certificate is now chosen by the server of the connection: the one used by the accounts on it. Only if accounts on the same server use different certificates, the default account decides, as the TLS connection is shared. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../utils/ssl/ClientCertificateSelector.kt | 45 ++++++++++++++ .../nextcloud/talk/utils/ssl/KeyManager.java | 34 ++++++++-- .../ssl/ClientCertificateSelectorTest.kt | 62 +++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt b/app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt new file mode 100644 index 00000000000..c1c143ef7a1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils.ssl + +import com.nextcloud.talk.data.user.model.User +import java.net.URI +import java.net.URISyntaxException + +/** + * Chooses the client certificate of the account a TLS connection is for. + * + * The TLS handshake only knows the server, not the account of the request. So the certificate is chosen by the + * server: the one of the accounts on it. Only if accounts on the same server use different certificates, the + * default account decides, as the connection may then be reused by all of them. + */ +object ClientCertificateSelector { + + /** + * Returns the alias of the client certificate for a connection to [host], or null if no account on it uses one. + * Without a [host], the certificate of [defaultUser] is used. + */ + @JvmStatic + fun selectAlias(host: String?, users: List, defaultUser: User?): String? { + val usersOnHost = users.filter { host != null && hostOf(it).equals(host, ignoreCase = true) } + val aliases = usersOnHost.mapNotNull { aliasOf(it) }.toSet() + return when { + host == null -> aliasOf(defaultUser) + aliases.size <= 1 -> aliases.firstOrNull() + else -> defaultUser?.takeIf { default -> usersOnHost.any { it.id == default.id } }?.let { aliasOf(it) } + } + } + + private fun aliasOf(user: User?): String? = user?.clientCertificate?.takeIf { it.isNotEmpty() } + + private fun hostOf(user: User): String? = + try { + user.baseUrl?.let { URI(it).host } + } catch (e: URISyntaxException) { + null + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java b/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java index 28791fc0788..885e3cd9a60 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java @@ -27,6 +27,8 @@ import java.util.List; import java.util.Set; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; import javax.net.ssl.X509KeyManager; import kotlin.coroutines.EmptyCoroutineContext; import kotlinx.coroutines.BuildersKt; @@ -49,20 +51,28 @@ public KeyManager(X509KeyManager keyManager, UserManager userManager, AppPrefere context = NextcloudTalkApplication.Companion.getSharedApplication().getApplicationContext(); } + /** + * Chooses the certificate of the accounts on the server of the connection, see {@link ClientCertificateSelector}. The + * handshake doesn't know the account of the request, so the default account alone would offer the wrong + * certificate for the requests of other accounts. + */ @Override public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) { String alias; - User currentUser = null; + List users = new ArrayList<>(); + User defaultUser = null; try { - currentUser = BuildersKt.runBlocking( + users = BuildersKt.runBlocking( + EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> userManager.getUsers(continuation)); + defaultUser = BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, (scope, continuation) -> userManager.getDefaultUser(continuation)); } catch (InterruptedException e) { - Log.e(TAG, "Interrupted while getting the current user: " + e.getLocalizedMessage()); + Log.e(TAG, "Interrupted while getting the users: " + e.getLocalizedMessage()); Thread.currentThread().interrupt(); } - if ((currentUser != null && - !TextUtils.isEmpty(alias = currentUser.getClientCertificate())) || + if (!TextUtils.isEmpty(alias = ClientCertificateSelector.selectAlias(getPeerHost(socket), users, defaultUser)) || !TextUtils.isEmpty(alias = appPreferences.getTemporaryClientCertAlias()) && new ArrayList<>(Arrays.asList(getClientAliases())).contains(alias)) { return alias; @@ -71,6 +81,20 @@ public String chooseClientAlias(String[] strings, Principal[] principals, Socket return null; } + @Nullable + private static String getPeerHost(@Nullable Socket socket) { + if (socket instanceof SSLSocket) { + SSLSession session = ((SSLSocket) socket).getHandshakeSession(); + if (session != null && session.getPeerHost() != null) { + return session.getPeerHost(); + } + } + if (socket != null && socket.getInetAddress() != null) { + return socket.getInetAddress().getHostName(); + } + return null; + } + @Override public String chooseServerAlias(String s, Principal[] principals, Socket socket) { return null; diff --git a/app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt new file mode 100644 index 00000000000..a1d8a38f9a9 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils.ssl + +import com.nextcloud.talk.data.user.model.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ClientCertificateSelectorTest { + + private val withCertA = User(id = 1, baseUrl = "https://a.example.com", clientCertificate = "certA") + private val withoutCertB = User(id = 2, baseUrl = "https://b.example.com") + private val withCertB = User(id = 3, baseUrl = "https://b.example.com", clientCertificate = "certB") + private val otherCertB = User(id = 4, baseUrl = "https://b.example.com", clientCertificate = "otherB") + + @Test + fun `the certificate of the server's account is used, not the default account's`() { + val alias = ClientCertificateSelector.selectAlias( + "a.example.com", + listOf(withCertA, withoutCertB), + withoutCertB + ) + + assertEquals("certA", alias) + } + + @Test + fun `no certificate for a server whose accounts use none, even if the default account has one`() { + val alias = ClientCertificateSelector.selectAlias("b.example.com", listOf(withCertA, withoutCertB), withCertA) + + assertNull(alias) + } + + @Test + fun `accounts on the same server with one certificate share it`() { + val alias = ClientCertificateSelector.selectAlias( + "b.example.com", + listOf(withoutCertB, withCertB), + withoutCertB + ) + + assertEquals("certB", alias) + } + + @Test + fun `different certificates on the same server are decided by the default account`() { + val users = listOf(withCertB, otherCertB, withCertA) + + assertEquals("otherB", ClientCertificateSelector.selectAlias("b.example.com", users, otherCertB)) + assertNull(ClientCertificateSelector.selectAlias("b.example.com", users, withCertA)) + } + + @Test + fun `without a host the default account's certificate is used`() { + assertEquals("certA", ClientCertificateSelector.selectAlias(null, listOf(withCertA, withCertB), withCertA)) + } +} From 974c16c9a65548c231abb28cbbc9eab7c925de70 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 16:16:08 +0200 Subject: [PATCH 73/76] refactor(user): simplify resolving the account of a rejected request Splits the lookup of the account whose credentials a request carries from building the wipe candidate, and replaces the chain of early returns. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/utils/RemoteWipeInterceptor.kt | 45 ++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt index 8446a68ce34..f29e9f8758d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt @@ -79,36 +79,29 @@ class RemoteWipeInterceptor( * be on the same server, so the server alone does not identify the account. */ private fun resolveWipeCandidate(request: Request): WipeCandidate? { - val requestUrl = request.url.toString() - val authorization = request.header(AUTHORIZATION) - if (authorization == null) { - Log.d(TAG, "401 for a request without credentials, ignoring: $requestUrl") - return null - } - val user = runBlocking { userManager.getUsers() } - .firstOrNull { - it.baseUrl != null && - requestUrl.startsWith(it.baseUrl!!) && - ApiUtils.getCredentials(it.username, it.token) == authorization - } - if (user == null) { - Log.d(TAG, "No known user matches the credentials and base URL of $requestUrl, ignoring") - return null - } + val user = findUserWithCredentialsOf(request) + val token = user?.token + val userId = user?.id - val token = user.token - if (token == null) { - Log.d(TAG, "User ${user.id} has no token, ignoring") - return null + return if (user != null && token != null && userId != null) { + WipeCandidate(user, token, userId) + } else { + Log.d(TAG, "401 for a request without the credentials of a known account, ignoring: ${request.url}") + null } + } - val userId = user.id - if (userId == null) { - Log.d(TAG, "User has no id, ignoring") - return null - } + /** + * The stored account on the server of [request] whose credentials the request carries, if any. + */ + private fun findUserWithCredentialsOf(request: Request): User? { + val credentials = request.header(AUTHORIZATION) ?: return null + val requestUrl = request.url.toString() - return WipeCandidate(user, token, userId) + return runBlocking { userManager.getUsers() }.firstOrNull { user -> + val isOnServerOfRequest = user.baseUrl?.let { requestUrl.startsWith(it) } == true + isOnServerOfRequest && ApiUtils.getCredentials(user.username, user.token) == credentials + } } private fun isWipeRequestedByServer(candidate: WipeCandidate): Boolean { From 381d44278672157a8c0633b6df0d9a3e636d3041 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 16:39:13 +0200 Subject: [PATCH 74/76] fix(security): only send credentials to URLs of the account's own server Whether an image request got the account's credentials was decided by checking whether its URL starts with the server's base URL. That also matches other hosts like https://cloud.example.com.attacker.test for https://cloud.example.com, so e.g. a link preview image could send the account's credentials to a foreign host. UriUtils.isOnServer() now compares scheme, host, port and base path, and is used wherever credentials are attached based on the URL. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/extensions/ImageViewExtensions.kt | 5 ++-- .../com/nextcloud/talk/ui/chat/LinkMessage.kt | 3 ++- .../talk/utils/RemoteWipeInterceptor.kt | 2 +- .../java/com/nextcloud/talk/utils/UriUtils.kt | 18 +++++++++++++ .../com/nextcloud/talk/utils/UriUtilsTest.kt | 26 +++++++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt index 26bc6fd8e48..88929ce0868 100644 --- a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt @@ -49,6 +49,7 @@ import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.UriUtils import kotlin.math.min private const val ROUNDING_PIXEL = 16f @@ -230,7 +231,7 @@ fun ImageView.loadThumbnail(url: String, user: User): io.reactivex.disposables.D layers[1] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground) requestBuilder.placeholder(LayerDrawable(layers)) - if (url.startsWith(user.baseUrl!!) && + if (UriUtils.isOnServer(url, user.baseUrl!!) && (url.contains("index.php/core/preview") || url.contains("/avatar/") || url.contains("remote.php/dav/")) ) { requestBuilder.addHeader( @@ -256,7 +257,7 @@ fun ImageView.loadImage(url: String, user: User, placeholder: Drawable? = null): .error(finalPlaceholder) .transformations(RoundedCornersTransformation(ROUNDING_PIXEL, ROUNDING_PIXEL, ROUNDING_PIXEL, ROUNDING_PIXEL)) - if (url.startsWith(user.baseUrl!!) && + if (UriUtils.isOnServer(url, user.baseUrl!!) && (url.contains("index.php/core/preview") || url.contains("/avatar/") || url.contains("remote.php/dav/")) ) { requestBuilder.addHeader( diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt index 9e72159bce6..3998c909417 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -34,6 +34,7 @@ import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.contacts.load import com.nextcloud.talk.models.json.opengraph.OpenGraphObjectDto import com.nextcloud.talk.ui.theme.LocalOpenGraphFetcher +import com.nextcloud.talk.utils.UriUtils import androidx.core.net.toUri private val previewImageHeight = 120.dp @@ -153,7 +154,7 @@ private fun LinkPreviewImage( ) { thumbUrl?.takeIf { it.isNotBlank() }?.let { // Credentials only for images of the account's own server, never for external ones. - val isOwnServer = typeContent.serverBaseUrl?.let { baseUrl -> it.startsWith(baseUrl) } == true + val isOwnServer = typeContent.serverBaseUrl?.let { baseUrl -> UriUtils.isOnServer(it, baseUrl) } == true val loadedImage = load( imageUri = it, context = context, diff --git a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt index f29e9f8758d..d7ab1301f1c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt @@ -99,7 +99,7 @@ class RemoteWipeInterceptor( val requestUrl = request.url.toString() return runBlocking { userManager.getUsers() }.firstOrNull { user -> - val isOnServerOfRequest = user.baseUrl?.let { requestUrl.startsWith(it) } == true + val isOnServerOfRequest = user.baseUrl?.let { UriUtils.isOnServer(requestUrl, it) } == true isOnServerOfRequest && ApiUtils.getCredentials(user.username, user.token) == credentials } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt index 955d836259d..c56f8d4b096 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt @@ -8,11 +8,29 @@ package com.nextcloud.talk.utils import androidx.core.net.toUri +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull class UriUtils { companion object { fun hasHttpProtocolPrefixed(uri: String): Boolean = uri.startsWith("http://") || uri.startsWith("https://") + /** + * Whether [url] points to the server at [baseUrl]: same scheme, host and port, and within its path. Unlike a + * prefix check, this rejects e.g. `https://cloud.example.com.attacker.test` for `https://cloud.example.com`, + * so it can decide whether to send the account's credentials. + */ + fun isOnServer(url: String, baseUrl: String): Boolean { + val target = url.toHttpUrlOrNull() + val server = baseUrl.toHttpUrlOrNull() + val serverPath = server?.encodedPath?.trimEnd('/').orEmpty() + return target != null && + server != null && + target.scheme == server.scheme && + target.host == server.host && + target.port == server.port && + (target.encodedPath == serverPath || target.encodedPath.startsWith("$serverPath/")) + } + fun extractInstanceInternalFileFileId(url: String): String { // https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41 return url.toUri().getQueryParameter("fileid").toString() diff --git a/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt index 37ba993f26b..065d56597ce 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt @@ -28,4 +28,30 @@ class UriUtilsTest { val link = UriUtils.getMessageLink("https://example.com/nextcloud", "token1", 7) Assert.assertEquals("https://example.com/nextcloud/call/token1#message_7", link) } + + @Test + fun `isOnServer accepts URLs of the server`() { + Assert.assertTrue(UriUtils.isOnServer("$SERVER/core/preview?x=1", SERVER)) + Assert.assertTrue(UriUtils.isOnServer("$SERVER/", "$SERVER/")) + Assert.assertTrue(UriUtils.isOnServer("$SUBFOLDER_SERVER/index.php", SUBFOLDER_SERVER)) + } + + @Test + fun `isOnServer rejects other hosts that start with the server`() { + Assert.assertFalse(UriUtils.isOnServer("https://cloud.example.com.attacker.test/image", SERVER)) + Assert.assertFalse(UriUtils.isOnServer("https://cloud.example.com@attacker.test/image", SERVER)) + } + + @Test + fun `isOnServer rejects another scheme, port or path`() { + Assert.assertFalse(UriUtils.isOnServer("http://cloud.example.com/image", SERVER)) + Assert.assertFalse(UriUtils.isOnServer("https://cloud.example.com:8443/image", SERVER)) + Assert.assertFalse(UriUtils.isOnServer("${SUBFOLDER_SERVER}2/image", SUBFOLDER_SERVER)) + Assert.assertFalse(UriUtils.isOnServer("not a url", SERVER)) + } + + companion object { + private const val SERVER = "https://cloud.example.com" + private const val SUBFOLDER_SERVER = "https://example.com/nextcloud" + } } From 06acebbf0bae24672ab19cc2e3d0beed83119cbb Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 16:47:48 +0200 Subject: [PATCH 75/76] fix(shareditems): authenticate shared items with the login name The media viewer and the shared items screen built their credentials from the user id instead of the login name. For accounts whose login name differs from the user id, e.g. an email address, the requests were only authenticated by a shared session cookie and got 401 since authenticated requests no longer use cookies. They now use the login name. A failed response is now reported as an HttpException instead of a NullPointerException on the missing body. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/mediaviewer/viewmodels/MediaViewerViewModel.kt | 3 ++- .../talk/shareditems/repositories/SharedItemsRepository.kt | 3 +++ .../shareditems/repositories/SharedItemsRepositoryImpl.kt | 4 ++++ .../talk/shareditems/viewmodels/SharedItemsViewModel.kt | 3 ++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt index 930e4afac0a..694c6f7e544 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -83,7 +83,8 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository fun initialize(user: User, roomToken: String, seedItems: List, startMessageId: Long) { this.user = user repositoryParameters = SharedItemsRepository.Parameters( - user.userId!!, + // The login name, as it authenticates the requests. The user id can differ from it. + user.username!!, user.token!!, user.baseUrl!!, roomToken diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt index bd4f64442e8..eed91f3b87d 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt @@ -19,5 +19,8 @@ interface SharedItemsRepository { fun availableTypes(parameters: Parameters): Observable> + /** + * [userName] is the login name of the account, which authenticates the requests together with [userToken]. + */ data class Parameters(val userName: String, val userToken: String, val baseUrl: String, val roomToken: String) } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt index d083ca9ddea..68ebf273683 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt @@ -26,6 +26,7 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils import io.reactivex.Observable +import retrofit2.HttpException import retrofit2.Response import java.util.Locale import javax.inject.Inject @@ -65,6 +66,9 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr chatLastGiven = response.headers()["x-chat-last-given"]!!.toInt() } + if (!response.isSuccessful) { + throw HttpException(response) + } val mediaItems = response.body()!!.ocs!!.data if (mediaItems != null) { for (it in mediaItems) { diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt b/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt index 3de476c5636..cd2f672013f 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt @@ -41,7 +41,8 @@ class SharedItemsViewModel @Inject constructor(private val repository: SharedIte fun initialize(user: User, roomToken: String) { repositoryParameters = SharedItemsRepository.Parameters( - user.userId!!, + // The login name, as it authenticates the requests. The user id can differ from it. + user.username!!, user.token!!, user.baseUrl!!, roomToken From d79bf073af13ca3a36af2f2bcc2fb4cc8f0de815 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 30 Sep 2026 17:48:17 +0200 Subject: [PATCH 76/76] fix(user): resolve a rejected account from the request that got the 401 RemoteWipeInterceptor is an application interceptor, so it sees the response after redirects. It resolved the account from the original request, which still carries the credentials after a redirect to another host, although OkHttp dropped them for the redirected request that got the 401. It now uses the request of the response. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Marcel Hibbe --- .../talk/utils/RemoteWipeInterceptor.kt | 9 ++++-- .../talk/utils/RemoteWipeInterceptorTest.kt | 29 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt index d7ab1301f1c..ef6df7cd403 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt @@ -53,14 +53,17 @@ class RemoteWipeInterceptor( val response = chain.proceed(chain.request()) if (response.code != HTTP_UNAUTHORIZED) return response - Log.d(TAG, "Received 401 for ${chain.request().url}") + // The request that got the 401. After a redirect, it differs from chain.request(): e.g. OkHttp drops the + // credentials when redirecting to another host, so that 401 says nothing about the original credentials. + val rejectedRequest = response.request + Log.d(TAG, "Received 401 for ${rejectedRequest.url}") - if (WIPE_PATH in chain.request().url.encodedPath) { + if (WIPE_PATH in rejectedRequest.url.encodedPath) { Log.d(TAG, "401 was from the wipe endpoint itself, ignoring to avoid recursion") return response } - val candidate = resolveWipeCandidate(chain.request()) ?: return response + val candidate = resolveWipeCandidate(rejectedRequest) ?: return response if (!handledUserIds.add(candidate.userId)) { Log.d(TAG, "User ${candidate.userId} was already handled, ignoring") return response diff --git a/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt index 9ef533203dc..390338d21cf 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt @@ -38,12 +38,12 @@ class RemoteWipeInterceptorTest { mock() ) - private val marcel = User(id = 1, username = "marcel", token = "tokenA", baseUrl = BASE_URL) - private val marcel2 = User(id = 2, username = "marcel2", token = "tokenB", baseUrl = BASE_URL) + private val userA = User(id = 1, username = "userA", token = "tokenA", baseUrl = BASE_URL) + private val userB = User(id = 2, username = "userB", token = "tokenB", baseUrl = BASE_URL) @Test fun `a 401 for a request without credentials removes no account`() { - wheneverBlocking { userManager.getUsers() } doReturn listOf(marcel, marcel2) + wheneverBlocking { userManager.getUsers() } doReturn listOf(userA, userB) val response = interceptor.intercept(chainAnswering401(Request.Builder().url(PREVIEW_URL).build())) @@ -53,10 +53,10 @@ class RemoteWipeInterceptorTest { @Test fun `a 401 for credentials of no stored account removes no account`() { - wheneverBlocking { userManager.getUsers() } doReturn listOf(marcel, marcel2) + wheneverBlocking { userManager.getUsers() } doReturn listOf(userA, userB) val request = Request.Builder() .url(PREVIEW_URL) - .header("Authorization", Credentials.basic("marcel2", "outdatedToken")) + .header("Authorization", Credentials.basic("userB", "outdatedToken")) .build() interceptor.intercept(chainAnswering401(request)) @@ -64,9 +64,24 @@ class RemoteWipeInterceptorTest { verifyBlocking(userManager, never()) { scheduleUserForDeletionWithId(any()) } } - private fun chainAnswering401(request: Request): Interceptor.Chain { + @Test + fun `a 401 after a redirect to another host removes no account`() { + wheneverBlocking { userManager.getUsers() } doReturn listOf(userA, userB) + val original = Request.Builder() + .url(PREVIEW_URL) + .header("Authorization", Credentials.basic("userA", "tokenA")) + .build() + // OkHttp drops the credentials when following a redirect to another host. + val redirected = Request.Builder().url("https://other.example.org/image").build() + + interceptor.intercept(chainAnswering401(original, answeredRequest = redirected)) + + verifyBlocking(userManager, never()) { scheduleUserForDeletionWithId(any()) } + } + + private fun chainAnswering401(request: Request, answeredRequest: Request = request): Interceptor.Chain { val response = Response.Builder() - .request(request) + .request(answeredRequest) .protocol(Protocol.HTTP_1_1) .code(HTTP_UNAUTHORIZED) .message("Unauthorized")