From 566a2d049c9f5c3c8798277b34cb02a211f5aeec Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 11:25:39 +0200 Subject: [PATCH 01/10] feat(chat): show server maintenance mode banner in chat Detects HTTP 503 responses carrying the X-Nextcloud-Maintenance-Mode header during chat message polling and surfaces a red banner in the message input area, mirroring the "Connection lost" banner and the maintenance-mode banner already shown in the conversation list. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/MessageInputFragment.kt | 6 +++++ .../talk/chat/data/ChatMessageRepository.kt | 2 ++ .../chat/data/network/ChatMessageSyncer.kt | 24 ++++++++++++++++++- .../network/OfflineFirstChatRepository.kt | 9 +++++++ .../talk/chat/domain/ChatPullResult.kt | 1 + .../talk/chat/viewmodels/ChatViewModel.kt | 2 ++ .../res/layout/fragment_message_input.xml | 11 +++++++++ 7 files changed, 54 insertions(+), 1 deletion(-) 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 5827bb6471..a1207a93b9 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -331,6 +331,12 @@ class MessageInputFragment : Fragment() { binding.fragmentCallStarted.visibility = if (hasCall) View.VISIBLE else View.GONE } } + + viewLifecycleOwner.lifecycleScope.launch { + chatActivity.chatViewModel.maintenanceModeFlow.collect { isMaintenanceMode -> + binding.fragmentMaintenanceMode.visibility = if (isMaintenanceMode) View.VISIBLE else View.GONE + } + } } private fun setReactionsOnly(spreedCapabilities: SpreedCapability) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index ad9830c654..77c542605e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -46,6 +46,8 @@ interface ChatMessageRepository : LifecycleAwareManager { val isLoadingFlow: Flow + val maintenanceModeFlow: Flow + // /** // * Used for informing the user of the underlying processing behind offline support, [String] is the key // * which is handled in a switch statement in ChatActivity. diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt index 85118eacdb..b05ac26995 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt @@ -87,6 +87,10 @@ class ChatMessageSyncer @Inject constructor( suspend fun onIncomingMessagesFromOthers() { // no-op by default } + + suspend fun onMaintenanceModeChanged(active: Boolean) { + // no-op by default + } } /** @@ -590,6 +594,12 @@ class ChatMessageSyncer @Inject constructor( ) HTTP_CODE_NOT_MODIFIED -> ChatPullResult.NotModified HTTP_CODE_PRECONDITION_FAILED -> ChatPullResult.PreconditionFailed + HTTP_CODE_SERVICE_UNAVAILABLE -> + if (response.headers()[MAINTENANCE_MODE_HEADER] == "1") { + ChatPullResult.MaintenanceMode + } else { + ChatPullResult.Error(HttpException(response)) + } else -> ChatPullResult.Error(HttpException(response)) } @@ -631,11 +641,14 @@ class ChatMessageSyncer @Inject constructor( val lookIntoFuture = fieldMap["lookIntoFuture"] == 1 return when (val result = pullMessagesFlow(target, fieldMap).first()) { - is ChatPullResult.Success -> + is ChatPullResult.Success -> { + events.onMaintenanceModeChanged(false) handleSuccessfulPull(target, result, queriedMessageId, lookIntoFuture, events) + } is ChatPullResult.NotModified -> { Log.d(TAG, "Server returned NOT_MODIFIED, nothing to update") + events.onMaintenanceModeChanged(false) if (lookIntoFuture && queriedMessageId != null) { // the server confirmed there is nothing newer than the queried message, so // the queried message is a valid HTTP-synced anchor @@ -646,9 +659,16 @@ class ChatMessageSyncer @Inject constructor( is ChatPullResult.PreconditionFailed -> { Log.d(TAG, "Server returned PRECONDITION_FAILED, nothing to update") + events.onMaintenanceModeChanged(false) NOTHING_SYNCED } + is ChatPullResult.MaintenanceMode -> { + Log.d(TAG, "Server is currently in maintenance mode") + events.onMaintenanceModeChanged(true) + SYNC_FAILED + } + is ChatPullResult.Error -> { Log.e(TAG, "Error pulling messages from server", result.throwable) SYNC_FAILED @@ -1003,6 +1023,8 @@ class ChatMessageSyncer @Inject constructor( private const val HTTP_CODE_OK: Int = 200 private const val HTTP_CODE_NOT_MODIFIED = 304 private const val HTTP_CODE_PRECONDITION_FAILED = 412 + private const val HTTP_CODE_SERVICE_UNAVAILABLE = 503 + private const val MAINTENANCE_MODE_HEADER = "X-Nextcloud-Maintenance-Mode" private const val MAX_PULL_ATTEMPTS = 5 private const val RETRY_LIMIT_SECOND_ATTEMPT = 50 private const val RETRY_LIMIT_THIRD_ATTEMPT = 10 diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index a55885699b..13b9b7ca1f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -130,6 +130,11 @@ class OfflineFirstChatRepository @Inject constructor( private val _isLoadingFlow: MutableStateFlow = MutableStateFlow(false) + override val maintenanceModeFlow: Flow + get() = _maintenanceModeFlow + + private val _maintenanceModeFlow: MutableStateFlow = MutableStateFlow(false) + private var newXChatLastCommonRead: Int? = null private var itIsPaused = false @@ -188,6 +193,10 @@ class OfflineFirstChatRepository @Inject constructor( override suspend fun onIncomingMessagesFromOthers() { _incomingMessageFlow.emit(Unit) } + + override suspend fun onMaintenanceModeChanged(active: Boolean) { + _maintenanceModeFlow.value = active + } } override suspend fun loadInitialMessages(withNetworkParams: Bundle) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt index 50e5c29a0b..daef6c8a3e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt @@ -14,5 +14,6 @@ sealed class ChatPullResult { object NotModified : ChatPullResult() object PreconditionFailed : ChatPullResult() + object MaintenanceMode : ChatPullResult() data class Error(val throwable: Throwable) : ChatPullResult() } 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 30ab6b1e4c..9e636ceb56 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 @@ -564,6 +564,8 @@ class ChatViewModel @AssistedInject constructor( val isLoadingFlow = chatRepository.isLoadingFlow + val maintenanceModeFlow = chatRepository.maintenanceModeFlow + sealed interface ViewState object GetReminderStartState : ViewState diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml index aec5fc6753..df4eeba711 100644 --- a/app/src/main/res/layout/fragment_message_input.xml +++ b/app/src/main/res/layout/fragment_message_input.xml @@ -49,6 +49,17 @@ android:visibility="gone" tools:visibility="visible" /> + + Date: Wed, 26 Aug 2026 11:43:21 +0200 Subject: [PATCH 02/10] fix(conv-list): propagate room-sync network errors to the UI getRoomsFromServer() caught and only logged exceptions from the background room-list sync, so GetRoomsErrorState was never produced by the current getRooms() path (roomListFlow only observes the local database, not the network call). This made handleHttpExceptions unreachable, silently breaking the unauthorized/outdated-client dialogs and the maintenance-mode banner. Adds a getRoomsErrorFlow that the repository emits into instead of swallowing the exception, collected by the view model into the existing GetRoomsErrorState. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../data/OfflineConversationsRepository.kt | 7 +++++++ .../data/network/OfflineFirstConversationsRepository.kt | 5 +++++ .../viewmodels/ConversationsListViewModel.kt | 9 +++++++++ 3 files changed, 21 insertions(+) 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 72d9efe613..e36b8ed1aa 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 @@ -31,6 +31,13 @@ interface OfflineConversationsRepository { */ val syncErrorFlow: Flow + /** + * Network/HTTP failures from [getRooms]'s background sync (e.g. 401, 426, 503 with the + * maintenance-mode header). [roomListFlow] only observes the local database and never fails + * from a network error, so the UI needs this separate stream to react to them. + */ + val getRoomsErrorFlow: Flow + /** * Stream of a single conversation, for use in each conversations settings. */ 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 1c2bf927cf..90a66caff6 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 @@ -88,6 +88,10 @@ class OfflineFirstConversationsRepository @Inject constructor( get() = _syncErrorFlow private val _syncErrorFlow: MutableSharedFlow = MutableSharedFlow() + override val getRoomsErrorFlow: Flow + get() = _getRoomsErrorFlow + private val _getRoomsErrorFlow: MutableSharedFlow = MutableSharedFlow() + private val scope = CoroutineScope(Dispatchers.IO) sealed interface ConversationResult { @@ -217,6 +221,7 @@ class OfflineFirstConversationsRepository @Inject constructor( scope.launch { catchUpRoomsWithNewMessages(user, roomsWithNewMessages) } } catch (e: Exception) { Log.e(TAG, "Something went wrong when fetching conversations", e) + _getRoomsErrorFlow.emit(e) val hasCachedConversations = dao.getConversationsForUser(user.id!!).first().isNotEmpty() if (!hasCachedConversations) { _syncErrorFlow.emit(e) 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 31547455b0..35cd338c76 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 @@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -173,6 +174,14 @@ class ConversationsListViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + init { + viewModelScope.launch { + repository.getRoomsErrorFlow.collect { throwable -> + _getRoomsViewState.value = GetRoomsErrorState(throwable) + } + } + } + /** * Drives the shimmer skeleton visibility. Visible while rooms are still being loaded for * the first time (no data in the list yet). Hidden as soon as either the room list is From bc3ecbfad3dc2c730ff84da133e5e6e18f5c750a Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 12:51:32 +0200 Subject: [PATCH 03/10] refactor(network): centralize maintenance-mode/HTTP-status detection in a shared interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat and conversation list each had their own bespoke detection of server maintenance mode (HTTP 503 + X-Nextcloud-Maintenance-Mode header), each only covering their own network call. Replaces both with a single OkHttpClient interceptor that sees every request for every screen and account, and publishes status changes (maintenance mode, unauthorized, client-update-required) via EventBus for any screen to react to — mirroring how the iOS app's NCAPISessionManager.checkStatusCode works. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 8 +++ .../talk/chat/data/ChatMessageRepository.kt | 2 - .../chat/data/network/ChatMessageSyncer.kt | 24 +------ .../network/OfflineFirstChatRepository.kt | 9 --- .../talk/chat/domain/ChatPullResult.kt | 1 - .../talk/chat/viewmodels/ChatViewModel.kt | 8 ++- .../ConversationsListActivity.kt | 44 ++++--------- .../viewmodels/ConversationsListViewModel.kt | 9 --- .../talk/dagger/modules/RestModule.java | 9 +++ .../talk/events/ServerStatusEvent.kt | 17 +++++ .../talk/utils/HttpStatusInterceptor.kt | 65 +++++++++++++++++++ 11 files changed, 120 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/events/ServerStatusEvent.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt 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 c8713fd2a1..3e29d873fb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -135,6 +135,8 @@ import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityChatBinding +import com.nextcloud.talk.events.ServerStatus +import com.nextcloud.talk.events.ServerStatusEvent import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent import com.nextcloud.talk.jobs.DeleteConversationWorker @@ -4058,6 +4060,12 @@ class ChatActivity : } } + @Subscribe(threadMode = ThreadMode.MAIN) + fun onServerStatusEvent(event: ServerStatusEvent) { + if (!::conversationUser.isInitialized || event.accountId != conversationUser.id) return + chatViewModel.setMaintenanceMode(event.status == ServerStatus.MAINTENANCE_MODE) + } + fun sendPictureFromCamIntent() { if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index 77c542605e..ad9830c654 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -46,8 +46,6 @@ interface ChatMessageRepository : LifecycleAwareManager { val isLoadingFlow: Flow - val maintenanceModeFlow: Flow - // /** // * Used for informing the user of the underlying processing behind offline support, [String] is the key // * which is handled in a switch statement in ChatActivity. diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt index b05ac26995..85118eacdb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt @@ -87,10 +87,6 @@ class ChatMessageSyncer @Inject constructor( suspend fun onIncomingMessagesFromOthers() { // no-op by default } - - suspend fun onMaintenanceModeChanged(active: Boolean) { - // no-op by default - } } /** @@ -594,12 +590,6 @@ class ChatMessageSyncer @Inject constructor( ) HTTP_CODE_NOT_MODIFIED -> ChatPullResult.NotModified HTTP_CODE_PRECONDITION_FAILED -> ChatPullResult.PreconditionFailed - HTTP_CODE_SERVICE_UNAVAILABLE -> - if (response.headers()[MAINTENANCE_MODE_HEADER] == "1") { - ChatPullResult.MaintenanceMode - } else { - ChatPullResult.Error(HttpException(response)) - } else -> ChatPullResult.Error(HttpException(response)) } @@ -641,14 +631,11 @@ class ChatMessageSyncer @Inject constructor( val lookIntoFuture = fieldMap["lookIntoFuture"] == 1 return when (val result = pullMessagesFlow(target, fieldMap).first()) { - is ChatPullResult.Success -> { - events.onMaintenanceModeChanged(false) + is ChatPullResult.Success -> handleSuccessfulPull(target, result, queriedMessageId, lookIntoFuture, events) - } is ChatPullResult.NotModified -> { Log.d(TAG, "Server returned NOT_MODIFIED, nothing to update") - events.onMaintenanceModeChanged(false) if (lookIntoFuture && queriedMessageId != null) { // the server confirmed there is nothing newer than the queried message, so // the queried message is a valid HTTP-synced anchor @@ -659,16 +646,9 @@ class ChatMessageSyncer @Inject constructor( is ChatPullResult.PreconditionFailed -> { Log.d(TAG, "Server returned PRECONDITION_FAILED, nothing to update") - events.onMaintenanceModeChanged(false) NOTHING_SYNCED } - is ChatPullResult.MaintenanceMode -> { - Log.d(TAG, "Server is currently in maintenance mode") - events.onMaintenanceModeChanged(true) - SYNC_FAILED - } - is ChatPullResult.Error -> { Log.e(TAG, "Error pulling messages from server", result.throwable) SYNC_FAILED @@ -1023,8 +1003,6 @@ class ChatMessageSyncer @Inject constructor( private const val HTTP_CODE_OK: Int = 200 private const val HTTP_CODE_NOT_MODIFIED = 304 private const val HTTP_CODE_PRECONDITION_FAILED = 412 - private const val HTTP_CODE_SERVICE_UNAVAILABLE = 503 - private const val MAINTENANCE_MODE_HEADER = "X-Nextcloud-Maintenance-Mode" private const val MAX_PULL_ATTEMPTS = 5 private const val RETRY_LIMIT_SECOND_ATTEMPT = 50 private const val RETRY_LIMIT_THIRD_ATTEMPT = 10 diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index 13b9b7ca1f..a55885699b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -130,11 +130,6 @@ class OfflineFirstChatRepository @Inject constructor( private val _isLoadingFlow: MutableStateFlow = MutableStateFlow(false) - override val maintenanceModeFlow: Flow - get() = _maintenanceModeFlow - - private val _maintenanceModeFlow: MutableStateFlow = MutableStateFlow(false) - private var newXChatLastCommonRead: Int? = null private var itIsPaused = false @@ -193,10 +188,6 @@ class OfflineFirstChatRepository @Inject constructor( override suspend fun onIncomingMessagesFromOthers() { _incomingMessageFlow.emit(Unit) } - - override suspend fun onMaintenanceModeChanged(active: Boolean) { - _maintenanceModeFlow.value = active - } } override suspend fun loadInitialMessages(withNetworkParams: Bundle) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt index daef6c8a3e..50e5c29a0b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt @@ -14,6 +14,5 @@ sealed class ChatPullResult { object NotModified : ChatPullResult() object PreconditionFailed : ChatPullResult() - object MaintenanceMode : ChatPullResult() data class Error(val throwable: Throwable) : ChatPullResult() } 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 9e636ceb56..91d4a490c3 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 @@ -105,6 +105,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -564,7 +565,12 @@ class ChatViewModel @AssistedInject constructor( val isLoadingFlow = chatRepository.isLoadingFlow - val maintenanceModeFlow = chatRepository.maintenanceModeFlow + private val _maintenanceModeFlow = MutableStateFlow(false) + val maintenanceModeFlow: StateFlow = _maintenanceModeFlow.asStateFlow() + + fun setMaintenanceMode(active: Boolean) { + _maintenanceModeFlow.value = active + } sealed interface ViewState 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 1b41c8082c..bb0d8f952b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -63,6 +63,8 @@ import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.ConversationsListFetchDataEvent import com.nextcloud.talk.events.EventStatus +import com.nextcloud.talk.events.ServerStatus +import com.nextcloud.talk.events.ServerStatusEvent import com.nextcloud.talk.invitation.InvitationsActivity import com.nextcloud.talk.jobs.AccountRemovalWorker import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run @@ -121,7 +123,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.await import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import retrofit2.HttpException import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -204,7 +205,6 @@ class ConversationsListActivity : BaseActivity() { } else { currentUserProviderOld.currentUser.blockingGet() } - conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] @@ -455,7 +455,8 @@ class ConversationsListActivity : BaseActivity() { when (state) { is ConversationsListViewModel.GetRoomsErrorState -> { isRefreshingState.value = false - handleHttpExceptions(state.throwable) + Log.e(TAG, "Error observing the conversation list", state.throwable) + showErrorDialog() } else -> {} @@ -674,22 +675,15 @@ class ConversationsListActivity : BaseActivity() { } } - private fun handleHttpExceptions(throwable: Throwable) { - if (!networkMonitor.isOnline.value) return - - if (throwable is HttpException) { - when (throwable.code()) { - HTTP_UNAUTHORIZED -> showUnauthorizedDialog() - HTTP_CLIENT_UPGRADE_REQUIRED -> showOutdatedClientDialog() - HTTP_SERVICE_UNAVAILABLE -> showServiceUnavailableDialog(throwable) - else -> { - Log.e(TAG, "Http Exception in ConversationListActivity", throwable) - showErrorDialog() - } - } - } else { - Log.e(TAG, "Exception in ConversationListActivity", throwable) - showErrorDialog() + @Subscribe(threadMode = ThreadMode.MAIN) + fun onServerStatusEvent(event: ServerStatusEvent) { + if (event.accountId != currentUser?.id) return + + when (event.status) { + ServerStatus.UNAUTHORIZED -> showUnauthorizedDialog() + ServerStatus.CLIENT_UPDATE_REQUIRED -> showOutdatedClientDialog() + ServerStatus.MAINTENANCE_MODE -> isMaintenanceModeState.value = true + ServerStatus.OK -> isMaintenanceModeState.value = false } } @@ -1457,14 +1451,6 @@ class ConversationsListActivity : BaseActivity() { ) } - private fun showServiceUnavailableDialog(httpException: HttpException) { - if (httpException.response()?.headers()?.get(MAINTENANCE_MODE_HEADER_KEY) == "1") { - isMaintenanceModeState.value = true - } else { - showErrorDialog() - } - } - private fun showServerEOLDialog() { val dialogBuilder = MaterialAlertDialogBuilder(this) .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.ic_warning_white)) @@ -1563,12 +1549,8 @@ class ConversationsListActivity : BaseActivity() { private val TAG = ConversationsListActivity::class.java.simpleName const val BOTTOM_SHEET_DELAY: Long = 2500 const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 - const val HTTP_UNAUTHORIZED = 401 - const val HTTP_CLIENT_UPGRADE_REQUIRED = 426 const val CLIENT_UPGRADE_MARKET_LINK = "market://details?id=" const val CLIENT_UPGRADE_GPLAY_LINK = "https://play.google.com/store/apps/details?id=" - const val HTTP_SERVICE_UNAVAILABLE = 503 - const val MAINTENANCE_MODE_HEADER_KEY = "X-Nextcloud-Maintenance-Mode" const val REQUEST_POST_NOTIFICATIONS_PERMISSION = 111 const val DAYS_FOR_NOTIFICATION_WARNING = 5L const val NOTIFICATION_WARNING_DATE_NOT_SET = 0L 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 35cd338c76..31547455b0 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 @@ -55,7 +55,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -174,14 +173,6 @@ class ConversationsListViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) - init { - viewModelScope.launch { - repository.getRoomsErrorFlow.collect { throwable -> - _getRoomsViewState.value = GetRoomsErrorState(throwable) - } - } - } - /** * Drives the shimmer skeleton visibility. Visible while rooms are still being loaded for * the first time (no data in the list yet). Hidden as soon as either the room list is 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 c98fd2e82f..e4e3764897 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.HttpStatusInterceptor; import com.nextcloud.talk.utils.RemoteWipeInterceptor; import com.nextcloud.talk.utils.LoggingUtils; import com.nextcloud.talk.utils.preferences.AppPreferences; @@ -180,6 +181,12 @@ Dispatcher provideDispatcher() { return dispatcher; } + @Singleton + @Provides + HttpStatusInterceptor provideHttpStatusInterceptor(UserManager userManager) { + return new HttpStatusInterceptor(userManager); + } + @Singleton @Provides OkHttpClient provideHttpClient(Proxy proxy, AppPreferences appPreferences, @@ -188,6 +195,7 @@ OkHttpClient provideHttpClient(Proxy proxy, AppPreferences appPreferences, CookieManager cookieManager, Dispatcher dispatcher, UserManager userManager, + HttpStatusInterceptor httpStatusInterceptor, LoggingHttpInterceptor loggingHttpInterceptor) { OkHttpClient.Builder httpClient = new OkHttpClient.Builder(); @@ -222,6 +230,7 @@ OkHttpClient provideHttpClient(Proxy proxy, AppPreferences appPreferences, httpClient.addInterceptor(new HeadersInterceptor()); httpClient.addInterceptor(new RemoteWipeInterceptor(userManager, context, sslSocketFactoryCompat, trustManager)); + httpClient.addInterceptor(httpStatusInterceptor); httpClient.addInterceptor(loggingHttpInterceptor); return httpClient.build(); diff --git a/app/src/main/java/com/nextcloud/talk/events/ServerStatusEvent.kt b/app/src/main/java/com/nextcloud/talk/events/ServerStatusEvent.kt new file mode 100644 index 0000000000..2d9ffe3d5e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/events/ServerStatusEvent.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.events + +enum class ServerStatus { + OK, + UNAUTHORIZED, + CLIENT_UPDATE_REQUIRED, + MAINTENANCE_MODE +} + +data class ServerStatusEvent(val accountId: Long, val status: ServerStatus) diff --git a/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt new file mode 100644 index 0000000000..67f2b8953a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt @@ -0,0 +1,65 @@ +/* + * 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 com.nextcloud.talk.events.ServerStatus +import com.nextcloud.talk.events.ServerStatusEvent +import com.nextcloud.talk.users.UserManager +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.greenrobot.eventbus.EventBus +import java.util.concurrent.ConcurrentHashMap + +class HttpStatusInterceptor(private val userManager: UserManager) : Interceptor { + + private val lastKnownStatus = ConcurrentHashMap() + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + val accountId = resolveAccountId(request) ?: return response + val newStatus = statusFor(response) + val previousStatus = lastKnownStatus.put(accountId, newStatus) ?: ServerStatus.OK + + if (newStatus != previousStatus) { + EventBus.getDefault().post(ServerStatusEvent(accountId, newStatus)) + } + + return response + } + + private fun statusFor(response: Response): ServerStatus = + when (response.code) { + HTTP_UNAUTHORIZED -> ServerStatus.UNAUTHORIZED + HTTP_UPGRADE_REQUIRED -> ServerStatus.CLIENT_UPDATE_REQUIRED + HTTP_SERVICE_UNAVAILABLE -> + if (response.header(MAINTENANCE_MODE_HEADER) == "1") { + ServerStatus.MAINTENANCE_MODE + } else { + ServerStatus.OK + } + else -> ServerStatus.OK + } + + private fun resolveAccountId(request: Request): Long? { + val authorization = request.header("Authorization") + val users = userManager.users.blockingGet() + val user = users.firstOrNull { ApiUtils.getCredentials(it.username, it.token) == authorization } + ?: users.firstOrNull { it.baseUrl != null && request.url.toString().startsWith(it.baseUrl!!) } + return user?.id + } + + companion object { + private const val HTTP_UNAUTHORIZED = 401 + private const val HTTP_UPGRADE_REQUIRED = 426 + private const val HTTP_SERVICE_UNAVAILABLE = 503 + private const val MAINTENANCE_MODE_HEADER = "X-Nextcloud-Maintenance-Mode" + } +} From 94fb00020549acad5fff8d51a0b27663bc256e36 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 12:53:45 +0200 Subject: [PATCH 04/10] fix(network): seed HTTP status from interceptor for screens that start observing late HttpStatusInterceptor only posts a ServerStatusEvent on a status transition, so a screen that starts observing after the transition already happened never learns the current status. In practice, the conversation list is usually first to detect maintenance mode; by the time a chat is opened, no new transition occurs since the account is already flagged, so the chat banner never appeared even though the server was still in maintenance mode. Adds HttpStatusInterceptor.currentStatus(accountId) so ChatActivity and ConversationsListActivity can seed their state as soon as the account is known, in addition to reacting to future transitions. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 9 +++++++++ .../ConversationsListActivity.kt | 9 +++++++++ .../talk/utils/HttpStatusInterceptor.kt | 19 ++++++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) 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 3e29d873fb..8302361ec9 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -197,6 +197,7 @@ import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.FileViewerUtils +import com.nextcloud.talk.utils.HttpStatusInterceptor import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions @@ -292,6 +293,9 @@ class ChatActivity : @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var httpStatusInterceptor: HttpStatusInterceptor + @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory @@ -641,6 +645,11 @@ class ChatActivity : currentUserProvider.getCurrentUser() .onSuccess { user -> conversationUser = user + user.id?.let { accountId -> + chatViewModel.setMaintenanceMode( + httpStatusInterceptor.currentStatus(accountId) == ServerStatus.MAINTENANCE_MODE + ) + } handleIntent(intent) val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) 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 bb0d8f952b..02d5546729 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -91,6 +91,7 @@ import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom import com.nextcloud.talk.utils.FileUtils +import com.nextcloud.talk.utils.HttpStatusInterceptor import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions @@ -146,6 +147,9 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var httpStatusInterceptor: HttpStatusInterceptor + @Inject lateinit var contactsViewModel: ContactsViewModel @@ -205,6 +209,11 @@ class ConversationsListActivity : BaseActivity() { } else { currentUserProviderOld.currentUser.blockingGet() } + currentUser?.id?.let { accountId -> + isMaintenanceModeState.value = + httpStatusInterceptor.currentStatus(accountId) == ServerStatus.MAINTENANCE_MODE + } + conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] diff --git a/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt index 67f2b8953a..e3e7f0af9c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.utils +import android.util.Log import com.nextcloud.talk.events.ServerStatus import com.nextcloud.talk.events.ServerStatusEvent import com.nextcloud.talk.users.UserManager @@ -24,17 +25,32 @@ class HttpStatusInterceptor(private val userManager: UserManager) : Interceptor val request = chain.request() val response = chain.proceed(request) - val accountId = resolveAccountId(request) ?: return response + val accountId = resolveAccountId(request) + if (accountId == null) { + Log.w(TAG, "Could not resolve account for ${request.url}, skipping status check") + return response + } + val newStatus = statusFor(response) val previousStatus = lastKnownStatus.put(accountId, newStatus) ?: ServerStatus.OK if (newStatus != previousStatus) { + Log.d(TAG, "Status for account $accountId changed from $previousStatus to $newStatus (${request.url})") EventBus.getDefault().post(ServerStatusEvent(accountId, newStatus)) } return response } + /** + * The last known status for [accountId], for a screen that starts observing after the + * transition already happened (e.g. the conversation list detects maintenance mode before + * the user opens a chat) — [intercept] only posts an event *on change*, so a late observer + * needs to fetch the current status once instead of waiting for a transition that already + * happened. + */ + fun currentStatus(accountId: Long): ServerStatus = lastKnownStatus[accountId] ?: ServerStatus.OK + private fun statusFor(response: Response): ServerStatus = when (response.code) { HTTP_UNAUTHORIZED -> ServerStatus.UNAUTHORIZED @@ -57,6 +73,7 @@ class HttpStatusInterceptor(private val userManager: UserManager) : Interceptor } companion object { + private const val TAG = "HttpStatusInterceptor" private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_UPGRADE_REQUIRED = 426 private const val HTTP_SERVICE_UNAVAILABLE = 503 From 7bd959e8282153b80773defd820fe73681a641d3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:46:20 +0200 Subject: [PATCH 05/10] feat(ui): show the status banner on every fully-Compose screen via BaseActivity Centralizes maintenance-mode/offline detection into BaseActivity (account resolution, EventBus subscription, HttpStatusInterceptor seeding) instead of each screen wiring its own state, and adds the shared StatusBannerRow (moved to components/, self-padding for the status bar only when visible) to the conversation list plus the 12 other screens that are fully written in Compose. ChatActivity keeps its existing XML-based banner untouched, since it mixes XML and Compose rather than being fully Compose. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/account/ServerSelectionActivity.kt | 4 - .../nextcloud/talk/activities/BaseActivity.kt | 44 +++++++++++ .../com/nextcloud/talk/chat/ChatActivity.kt | 11 +-- .../talk/chat/ScheduledMessagesActivity.kt | 72 ++++++++--------- .../ui => components}/StatusBanner.kt | 13 +++- .../talk/contacts/ContactsActivity.kt | 17 +++- .../ConversationCreationActivity.kt | 18 +++-- .../ConversationInfoEditActivity.kt | 60 ++++++++------ .../ConversationsListActivity.kt | 26 ++----- .../ui/ConversationsListScreen.kt | 6 +- .../talk/diagnosis/DiagnosisActivity.kt | 33 ++++---- .../talk/location/GeocodingActivity.kt | 20 +++-- .../talk/location/LocationPickerActivity.kt | 22 ++++-- .../nextcloud/talk/logger/ui/LogsActivity.kt | 53 +++++++------ .../ListOpenConversationsActivity.kt | 32 +++++--- .../nextcloud/talk/profile/ProfileActivity.kt | 78 +++++++++++-------- .../talk/settings/SettingsActivity.kt | 4 - .../ThreadsOverviewActivity.kt | 57 ++++++++------ 18 files changed, 341 insertions(+), 229 deletions(-) rename app/src/main/java/com/nextcloud/talk/{conversationlist/ui => components}/StatusBanner.kt (80%) 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 35d2c1e1ff..a264255e14 100644 --- a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt @@ -34,7 +34,6 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication -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.Status @@ -66,9 +65,6 @@ class ServerSelectionActivity : BaseActivity() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var networkMonitor: NetworkMonitor - private var statusQueryDisposable: Disposable? = null private val onBackPressedCallback = object : OnBackPressedCallback(true) { 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 366c7989b2..5f3a14aff8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -38,9 +38,14 @@ 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.network.NetworkMonitor import com.nextcloud.talk.events.CertificateEvent import com.nextcloud.talk.events.RemoteWipeEvent +import com.nextcloud.talk.events.ServerStatus +import com.nextcloud.talk.events.ServerStatusEvent +import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.lock.LockedActivity +import com.nextcloud.talk.utils.HttpStatusInterceptor import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.DisplayUtils @@ -54,6 +59,9 @@ 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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -96,6 +104,15 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var logger: Logger + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var httpStatusInterceptor: HttpStatusInterceptor + + private val maintenanceModeState = MutableStateFlow(false) + val maintenanceModeFlow: StateFlow = maintenanceModeState.asStateFlow() + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR @@ -136,6 +153,33 @@ open class BaseActivity : AppCompatActivity() { cleanTempCertPreference() } + /** + * The account whose [ServerStatusEvent]s should drive [maintenanceModeFlow] for this screen. + * Defaults to the globally active account; override when a screen shows a different, + * specific account (e.g. a non-active account selected via an intent extra). + */ + protected open fun accountIdForStatusBanner(): Long? = currentUserProviderOld.currentUser.blockingGet()?.id + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + maintenanceModeState.value = accountIdForStatusBanner()?.let { + httpStatusInterceptor.currentStatus(it) == ServerStatus.MAINTENANCE_MODE + } ?: false + } + + @Subscribe(threadMode = ThreadMode.MAIN) + open fun onServerStatusEvent(event: ServerStatusEvent) { + if (event.accountId != accountIdForStatusBanner()) return + + when (event.status) { + ServerStatus.MAINTENANCE_MODE -> maintenanceModeState.value = true + ServerStatus.OK -> maintenanceModeState.value = false + else -> { + // UNAUTHORIZED / CLIENT_UPDATE_REQUIRED are handled by ConversationsListActivity's dialogs + } + } + } + public override fun onStart() { super.onStart() eventBus.register(this) 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 8302361ec9..066c8dc647 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -132,7 +132,6 @@ 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 import com.nextcloud.talk.databinding.ActivityChatBinding import com.nextcloud.talk.events.ServerStatus @@ -197,7 +196,6 @@ import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.FileViewerUtils -import com.nextcloud.talk.utils.HttpStatusInterceptor import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions @@ -290,12 +288,6 @@ class ChatActivity : @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var networkMonitor: NetworkMonitor - - @Inject - lateinit var httpStatusInterceptor: HttpStatusInterceptor - @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory @@ -4070,7 +4062,8 @@ class ChatActivity : } @Subscribe(threadMode = ThreadMode.MAIN) - fun onServerStatusEvent(event: ServerStatusEvent) { + override fun onServerStatusEvent(event: ServerStatusEvent) { + super.onServerStatusEvent(event) if (!::conversationUser.isInitialized || event.accountId != conversationUser.id) return chatViewModel.setMaintenanceMode(event.status == ServerStatus.MAINTENANCE_MODE) } 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 fa18d31a9c..72db535ceb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -93,7 +93,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.data.network.NetworkMonitor +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.models.json.chat.ChatUtils @@ -134,9 +134,6 @@ class ScheduledMessagesActivity : BaseActivity() { private lateinit var scheduledMessagesViewModel: ScheduledMessagesViewModel - @Inject - lateinit var networkMonitor: NetworkMonitor - private val roomToken: String by lazy { intent.getStringExtra(ROOM_TOKEN).orEmpty() } @@ -169,6 +166,8 @@ class ScheduledMessagesActivity : BaseActivity() { setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val currentUser by scheduledMessagesViewModel.currentUserState.collectAsStateWithLifecycle() + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() LaunchedEffect(Unit) { scheduledMessagesViewModel.loadCurrentUser() } @@ -179,37 +178,40 @@ 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) - } - ) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + 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) + } + ) + } } } // CompositionLocalProvider } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt b/app/src/main/java/com/nextcloud/talk/components/StatusBanner.kt similarity index 80% rename from app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt rename to app/src/main/java/com/nextcloud/talk/components/StatusBanner.kt index 6aec84d235..1043d98901 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt +++ b/app/src/main/java/com/nextcloud/talk/components/StatusBanner.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.conversationlist.ui +package com.nextcloud.talk.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -14,6 +14,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,9 +26,17 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.talk.R +/** + * Row of full-width red banners for offline/maintenance-mode status, meant to sit at the very + * top of a screen. Self-contained regarding the status bar inset: [Modifier.statusBarsPadding] is + * only applied when a row is actually visible, so a screen that drops this in gets zero extra + * space reserved in the common case (nothing showing) and correct clearance from the status bar + * whenever a row appears. + */ @Composable fun StatusBannerRow(isOffline: Boolean, isMaintenanceMode: Boolean) { - Column { + val showBanner = isOffline || isMaintenanceMode + Column(modifier = if (showBanner) Modifier.statusBarsPadding() else Modifier) { AnimatedVisibility( visible = isOffline, enter = expandVertically(), 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 98903df3db..52933550e1 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -11,7 +11,9 @@ package com.nextcloud.talk.contacts import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -19,6 +21,7 @@ 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.components.StatusBannerRow import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser @@ -61,14 +64,20 @@ class ContactsActivity : BaseActivity() { }.toSet().toMutableList() contactsViewModel.updateSelectedParticipants(selectedParticipants) + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme( colorScheme = colorScheme ) { ColoredStatusBar() - ContactsScreen( - contactsViewModel = contactsViewModel, - uiState = uiState.value - ) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + ContactsScreen( + contactsViewModel = contactsViewModel, + uiState = uiState.value + ) + } } } } 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 fd28e47cb6..a63830576a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -94,6 +94,7 @@ import com.nextcloud.talk.components.AvatarEditPanel import com.nextcloud.talk.components.AvatarEditPanelCallbacks import com.nextcloud.talk.components.AvatarEditPanelState import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.conversationcreation.ui.ConversationPresets @@ -131,15 +132,20 @@ class ConversationCreationActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current val currentUser by conversationCreationViewModel.currentUser.collectAsState() + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() MaterialTheme( colorScheme = colorScheme ) { - val user = currentUser - if (user == null) { - LoadingScreen() - } else { - val pickImage = remember(user) { PickImage(this@ConversationCreationActivity, user) } - ConversationCreationScreen(conversationCreationViewModel, context, pickImage) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + val user = currentUser + if (user == null) { + LoadingScreen() + } else { + val pickImage = remember(user) { PickImage(this@ConversationCreationActivity, user) } + ConversationCreationScreen(conversationCreationViewModel, context, pickImage) + } } } } 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 529728d890..d0bb432d98 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -12,6 +12,7 @@ import android.util.Log import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.LaunchedEffect @@ -27,6 +28,7 @@ import com.github.dhaval2404.imagepicker.ImagePicker import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditCallbacks import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditScreen import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel @@ -122,33 +124,41 @@ class ConversationInfoEditActivity : BaseActivity() { } } + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - ConversationInfoEditScreen( - uiState = uiState, - callbacks = ConversationInfoEditCallbacks( - onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, - onSaveClick = { conversationInfoEditViewModel.saveNameAndDescription() }, - onAvatarUploadClick = { - pickImage?.selectLocal(startImagePickerForResult = startImagePickerForResult) - }, - onAvatarChooseClick = { - pickImage?.selectRemote( - startSelectRemoteFilesIntentForResult = startSelectRemoteFilesIntentForResult - ) - }, - onAvatarCameraClick = { - pickImage?.takePicture(startTakePictureIntentForResult = startTakePictureIntentForResult) - }, - onAvatarDeleteClick = { conversationInfoEditViewModel.deleteAvatar() }, - onAvatarEmojiAvatarConfirmed = { emoji, color -> - conversationInfoEditViewModel.onEmojiAvatarConfirmed(emoji, color) - }, - onNameChange = { conversationInfoEditViewModel.updateConversationName(it) }, - onDescriptionChange = { conversationInfoEditViewModel.updateConversationDescription(it) } - ), - snackbarHostState = snackbarHostState - ) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + ConversationInfoEditScreen( + uiState = uiState, + callbacks = ConversationInfoEditCallbacks( + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onSaveClick = { conversationInfoEditViewModel.saveNameAndDescription() }, + onAvatarUploadClick = { + pickImage?.selectLocal(startImagePickerForResult = startImagePickerForResult) + }, + onAvatarChooseClick = { + pickImage?.selectRemote( + startSelectRemoteFilesIntentForResult = startSelectRemoteFilesIntentForResult + ) + }, + onAvatarCameraClick = { + pickImage?.takePicture( + startTakePictureIntentForResult = startTakePictureIntentForResult + ) + }, + onAvatarDeleteClick = { conversationInfoEditViewModel.deleteAvatar() }, + onAvatarEmojiAvatarConfirmed = { emoji, color -> + conversationInfoEditViewModel.onEmojiAvatarConfirmed(emoji, color) + }, + onNameChange = { conversationInfoEditViewModel.updateConversationName(it) }, + onDescriptionChange = { conversationInfoEditViewModel.updateConversationDescription(it) } + ), + snackbarHostState = snackbarHostState + ) + } } } } 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 02d5546729..81e6166bc3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -59,7 +59,6 @@ import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenCallbacks import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenState import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel -import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.ConversationsListFetchDataEvent import com.nextcloud.talk.events.EventStatus @@ -91,7 +90,6 @@ import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.HttpStatusInterceptor import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions @@ -144,12 +142,6 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var networkMonitor: NetworkMonitor - - @Inject - lateinit var httpStatusInterceptor: HttpStatusInterceptor - @Inject lateinit var contactsViewModel: ContactsViewModel @@ -159,7 +151,6 @@ class ConversationsListActivity : BaseActivity() { private var currentUser: User? = null private val snackbarHostState = SnackbarHostState() - private val isMaintenanceModeState = MutableStateFlow(false) private val showUnreadBubbleState = MutableStateFlow(false) private val isFabVisibleState = MutableStateFlow(true) private val showNotificationWarningState = MutableStateFlow(false) @@ -209,10 +200,6 @@ class ConversationsListActivity : BaseActivity() { } else { currentUserProviderOld.currentUser.blockingGet() } - currentUser?.id?.let { accountId -> - isMaintenanceModeState.value = - httpStatusInterceptor.currentStatus(accountId) == ServerStatus.MAINTENANCE_MODE - } conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] @@ -239,6 +226,8 @@ class ConversationsListActivity : BaseActivity() { initObservers() } + override fun accountIdForStatusBanner(): Long? = currentUser?.id + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_ACCOUNT_DIALOG_VISIBLE, showAccountDialogState.value) @@ -260,7 +249,7 @@ class ConversationsListActivity : BaseActivity() { viewThemeUtils = viewThemeUtils, isShowEcosystem = appPreferences.isShowEcosystem && !resources.getBoolean(R.bool.is_branded_client), snackbarHostState = snackbarHostState, - isMaintenanceModeFlow = isMaintenanceModeState, + isMaintenanceModeFlow = maintenanceModeFlow, isOnlineFlow = networkMonitor.isOnline, showUnreadBubbleFlow = showUnreadBubbleState, isFabVisibleFlow = isFabVisibleState, @@ -285,7 +274,6 @@ class ConversationsListActivity : BaseActivity() { onContactClick = { contactsViewModel.createRoom(ROOM_TYPE_ONE_ONE, null, it.actorId!!, null) }, onLoadMoreClick = { conversationsListViewModel.loadMoreMessages(context) }, onRefresh = { - isMaintenanceModeState.value = false isRefreshingState.value = true appPreferences.setConversationListPositionAndOffset(0, 0) fetchRooms() @@ -685,14 +673,16 @@ class ConversationsListActivity : BaseActivity() { } @Subscribe(threadMode = ThreadMode.MAIN) - fun onServerStatusEvent(event: ServerStatusEvent) { + override fun onServerStatusEvent(event: ServerStatusEvent) { + super.onServerStatusEvent(event) if (event.accountId != currentUser?.id) return when (event.status) { ServerStatus.UNAUTHORIZED -> showUnauthorizedDialog() ServerStatus.CLIENT_UPDATE_REQUIRED -> showOutdatedClientDialog() - ServerStatus.MAINTENANCE_MODE -> isMaintenanceModeState.value = true - ServerStatus.OK -> isMaintenanceModeState.value = false + else -> { + // MAINTENANCE_MODE / OK are handled by BaseActivity's maintenanceModeFlow + } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index a726cb35b9..e144c4bcde 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.talk.R import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.data.user.model.User @@ -247,7 +248,7 @@ fun ConversationsListScreen( Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { - Column(modifier = Modifier.fillMaxWidth().statusBarsPadding()) { + Column(modifier = Modifier.fillMaxWidth()) { StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) ConversationListTopBar( state = ConversationListTopBarState( @@ -271,7 +272,8 @@ fun ConversationsListScreen( onAvatarClick = callbacks.onAvatarClick, onNavigateBack = callbacks.onNavigateBack, onAccountChooserClick = callbacks.onAccountChooserClick - ) + ), + modifier = Modifier.statusBarsPadding() ) } } 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 ebd4d0c0ed..0bc786dbc7 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -41,6 +41,7 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.errorhandling.saveLogsAsZip +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -65,9 +66,6 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -182,19 +180,22 @@ private fun DiagnosisScreen( colorScheme = colorScheme ) { val isOnline = networkMonitor.isOnline.collectAsState().value - ColoredStatusBar() - Scaffold( - modifier = Modifier - .statusBarsPadding() - .displayCutoutPadding(), - topBar = { - StandardAppBar( - title = stringResource(R.string.nc_settings_diagnosis_title), - menuItems - ) - }, - content = { paddingValues -> - val viewState = diagnosisViewModel.notificationViewState.collectAsState().value + val isMaintenanceMode = maintenanceModeFlow.collectAsState().value + ColoredStatusBar() + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + Scaffold( + modifier = Modifier + .statusBarsPadding() + .displayCutoutPadding(), + topBar = { + StandardAppBar( + title = stringResource(R.string.nc_settings_diagnosis_title), + menuItems + ) + }, + content = { paddingValues -> + val viewState = diagnosisViewModel.notificationViewState.collectAsState().value Column( Modifier 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 b8a1e019f2..2f55cf3a5c 100644 --- a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt @@ -9,14 +9,18 @@ package com.nextcloud.talk.location import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column 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.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.location.components.GeocodingScreen import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.location.viewmodels.GeoCodingViewModel @@ -59,13 +63,19 @@ class GeocodingActivity : BaseActivity() { setContent { val colorScheme = viewThemeUtils.getColorScheme(this) + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - GeocodingScreen( - viewModel = viewModel, - onBack = { onBackPressedDispatcher.onBackPressed() }, - onAddressSelected = { address -> navigateToLocationPicker(address) } - ) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + GeocodingScreen( + viewModel = viewModel, + onBack = { onBackPressedDispatcher.onBackPressed() }, + onAddressSelected = { address -> navigateToLocationPicker(address) } + ) + } } } } 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 80b3aaa0b1..0c6bf21907 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -11,13 +11,17 @@ import android.os.Bundle import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column 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.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.extensions.getParcelableExtraProvider import com.nextcloud.talk.location.components.LocationPickerScreen import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION @@ -99,14 +103,20 @@ class LocationPickerActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) setContent { + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - LocationPickerScreen( - viewModel = viewModel, - onSearchClick = { navigateToGeocoding() }, - onBack = { onBackPressedDispatcher.onBackPressed() }, - onFinish = { finish() } - ) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + LocationPickerScreen( + viewModel = viewModel, + onSearchClick = { navigateToGeocoding() }, + onBack = { onBackPressedDispatcher.onBackPressed() }, + onFinish = { finish() } + ) + } } } } 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 827a9a67e8..993317bf9a 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 @@ -63,6 +63,7 @@ import com.nextcloud.talk.components.StandardAppBar import com.nextcloud.talk.diagnosis.buildDiagnosisReportText import com.nextcloud.talk.diagnosis.showShareReportDialog import com.nextcloud.talk.errorhandling.saveLogsAsZip +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.Level import com.nextcloud.talk.logger.LogEntry import com.nextcloud.talk.logger.LogsRepository @@ -125,32 +126,38 @@ class LogsActivity : BaseActivity() { } ) + val isOnline by networkMonitor.isOnline.collectAsState() + val isMaintenanceMode by maintenanceModeFlow.collectAsState() + ColoredStatusBar() - Scaffold( - modifier = Modifier - .statusBarsPadding() - .displayCutoutPadding(), - topBar = { - StandardAppBar( - title = stringResource(R.string.nc_logs_title), - menuItems = menuItems + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + Scaffold( + modifier = Modifier + .statusBarsPadding() + .displayCutoutPadding(), + topBar = { + StandardAppBar( + title = stringResource(R.string.nc_logs_title), + menuItems = menuItems + ) + } + ) { paddingValues -> + LogsContent( + state = LogsUiState( + entries = entries, + isLoading = isLoading, + totalSize = totalSize, + lostEntries = viewModel.lostEntries, + loggingEnabled = loggingEnabled, + advancedLogging = advancedLogging + ), + onLoggingEnabledChange = { viewModel.setLoggingEnabled(it) }, + onAdvancedLoggingChange = { viewModel.setAdvancedLogging(it) }, + onDisable = { deleteExisting -> viewModel.setLoggingEnabled(false, deleteExisting) }, + paddingValues = paddingValues ) } - ) { paddingValues -> - LogsContent( - state = LogsUiState( - entries = entries, - isLoading = isLoading, - totalSize = totalSize, - lostEntries = viewModel.lostEntries, - loggingEnabled = loggingEnabled, - advancedLogging = advancedLogging - ), - onLoggingEnabledChange = { viewModel.setLoggingEnabled(it) }, - onAdvancedLoggingChange = { viewModel.setAdvancedLogging(it) }, - onDisable = { deleteExisting -> viewModel.setLoggingEnabled(false, deleteExisting) }, - paddingValues = paddingValues - ) } LaunchedEffect(Unit) { 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 3b91c1b180..3dd4d1b4a6 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.openconversations import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.lifecycle.ViewModelProvider @@ -18,6 +19,7 @@ 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.components.StatusBannerRow import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.utils.adjustUIForAPILevel35 @@ -47,21 +49,27 @@ class ListOpenConversationsActivity : BaseActivity() { val viewState by openConversationsViewModel.viewState.collectAsStateWithLifecycle() val searchTerm by openConversationsViewModel.searchTerm.collectAsStateWithLifecycle() + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - OpenConversationsScreen( - viewState = viewState, - searchTerm = searchTerm, - userBaseUrl = user?.baseUrl, - listenerInput = OpenConversationsScreenListenerInput( - onSearchTermChange = { term -> - openConversationsViewModel.updateSearchTerm(term) - openConversationsViewModel.fetchConversations() - }, - onConversationClick = { conversation -> navigateToChat(conversation) }, - onBackClick = { onBackPressedDispatcher.onBackPressed() } + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + OpenConversationsScreen( + viewState = viewState, + searchTerm = searchTerm, + userBaseUrl = user?.baseUrl, + listenerInput = OpenConversationsScreenListenerInput( + onSearchTermChange = { term -> + openConversationsViewModel.updateSearchTerm(term) + openConversationsViewModel.fetchConversations() + }, + onConversationClick = { conversation -> navigateToChat(conversation) }, + onBackClick = { onBackPressedDispatcher.onBackPressed() } + ) ) - ) + } } } } 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 df36d6bbf3..2e0fdb3046 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -14,12 +14,14 @@ import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toFile import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.github.dhaval2404.imagepicker.ImagePicker import com.github.dhaval2404.imagepicker.ImagePicker.Companion.getError @@ -29,6 +31,7 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.userprofile.Scope @@ -128,47 +131,56 @@ class ProfileActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) setContent { + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - ProfileScreen( - state = profileUiState, - callbacks = ProfileCallbacks( - onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, - onEditSave = ::handleEditSave, - onAvatarUploadClick = { - pickImage.selectLocal(startImagePickerForResult = startImagePickerForResult) - }, - onAvatarChooseClick = { - pickImage.selectRemote( - startSelectRemoteFilesIntentForResult = startSelectRemoteFilesIntentForResult - ) - }, - onAvatarCameraClick = { - pickImage.takePicture(startTakePictureIntentForResult = startTakePictureIntentForResult) - }, - onAvatarDeleteClick = ::deleteAvatar, - onProfileEnabledChange = { enabled -> - profileUiState = profileUiState.copy(isProfileEnabled = enabled) - }, - onTextChange = { position, newText -> - profileItems.getOrNull(position)?.text = newText - }, - onScopeClick = { position, field -> - scopeSheetRequest = ScopeSheetRequest(position, field) - } - ) - ) - scopeSheetRequest?.let { req -> - ScopeModalBottomSheet( - showPrivate = req.field != Field.DISPLAYNAME && req.field != Field.EMAIL, - onScopeSelected = { scope -> updateItemScope(req.position, scope) }, - onDismiss = { scopeSheetRequest = null } + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + ProfileScreen( + state = profileUiState, + callbacks = buildProfileCallbacks() ) + scopeSheetRequest?.let { req -> + ScopeModalBottomSheet( + showPrivate = req.field != Field.DISPLAYNAME && req.field != Field.EMAIL, + onScopeSelected = { scope -> updateItemScope(req.position, scope) }, + onDismiss = { scopeSheetRequest = null } + ) + } } } } } + private fun buildProfileCallbacks() = + ProfileCallbacks( + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onEditSave = ::handleEditSave, + onAvatarUploadClick = { + pickImage.selectLocal(startImagePickerForResult = startImagePickerForResult) + }, + onAvatarChooseClick = { + pickImage.selectRemote( + startSelectRemoteFilesIntentForResult = startSelectRemoteFilesIntentForResult + ) + }, + onAvatarCameraClick = { + pickImage.takePicture(startTakePictureIntentForResult = startTakePictureIntentForResult) + }, + onAvatarDeleteClick = ::deleteAvatar, + onProfileEnabledChange = { enabled -> + profileUiState = profileUiState.copy(isProfileEnabled = enabled) + }, + onTextChange = { position, newText -> + profileItems.getOrNull(position)?.text = newText + }, + onScopeClick = { position, field -> + scopeSheetRequest = ScopeSheetRequest(position, field) + } + ) + override fun onResume() { super.onResume() 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 ed931df8e1..e5527c0308 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -61,7 +61,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.setAppTheme import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.ConversationsListActivity.Companion.NOTIFICATION_WARNING_DATE_NOT_SET -import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySettingsBinding import com.nextcloud.talk.diagnosis.DiagnosisActivity @@ -127,9 +126,6 @@ class SettingsActivity : @Inject lateinit var ncApi: NcApi - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var ncApiCoroutines: NcApiCoroutines 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 eabb185de2..09580f0a03 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity @@ -47,6 +48,7 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.ChatActivity.Companion.TAG import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.models.json.threads.ThreadInfo import com.nextcloud.talk.threadsoverview.components.ThreadRow @@ -89,38 +91,43 @@ class ThreadsOverviewActivity : BaseActivity() { setContent { val backgroundColor = colorResource(id = R.color.bg_default) + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() MaterialTheme( colorScheme = colorScheme ) { ColoredStatusBar() - Scaffold( - modifier = Modifier - .statusBarsPadding(), - topBar = { - StandardAppBar( - title = appbarTitle, - null - ) - }, - content = { paddingValues -> - val uiState by threadsOverviewViewModel.threadsListState.collectAsState() - - Column( - Modifier - .padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp) - .background(backgroundColor) - .fillMaxSize() - ) { - ThreadsOverviewScreen( - uiState, - onThreadClick = { roomToken, threadId -> - navigateToChatActivity(roomToken, threadId) - } + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + Scaffold( + modifier = Modifier + .statusBarsPadding(), + topBar = { + StandardAppBar( + title = appbarTitle, + null ) + }, + content = { paddingValues -> + val uiState by threadsOverviewViewModel.threadsListState.collectAsState() + + Column( + Modifier + .padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp) + .background(backgroundColor) + .fillMaxSize() + ) { + ThreadsOverviewScreen( + uiState, + onThreadClick = { roomToken, threadId -> + navigateToChatActivity(roomToken, threadId) + } + ) + } } - } - ) + ) + } } } } From 220ba08b63d2af4a3a63c60bb52eb73db5913ed2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:29:09 +0200 Subject: [PATCH 06/10] refactor(ui): extract status-banner wiring into BaseActivity.setContentWithStatusBanner Each fully-Compose screen repeated the same isOnline/isMaintenanceMode state collection and Column-wrapping to show the shared status banner. Adds BaseActivity.setContentWithStatusBanner(content), a drop-in replacement for setContent that collects the state once and renders StatusBannerRow above content, and switches all 12 screens to use it instead of duplicating the wiring. ConversationsListActivity is unchanged: its banner is embedded inside its own Scaffold's topBar alongside the conversation list's top bar, not the generic "wrap everything" shape the helper assumes. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 22 ++++++ .../talk/chat/ScheduledMessagesActivity.kt | 71 +++++++++---------- .../talk/contacts/ContactsActivity.kt | 20 ++---- .../ConversationInfoEditActivity.kt | 65 ++++++++--------- .../talk/diagnosis/DiagnosisActivity.kt | 37 +++++----- .../talk/location/GeocodingActivity.kt | 22 ++---- .../talk/location/LocationPickerActivity.kt | 25 ++----- .../nextcloud/talk/logger/ui/LogsActivity.kt | 54 +++++++------- .../ListOpenConversationsActivity.kt | 35 ++++----- .../nextcloud/talk/profile/ProfileActivity.kt | 30 +++----- .../ThreadsOverviewActivity.kt | 60 +++++++--------- 11 files changed, 190 insertions(+), 251 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 5f3a14aff8..20ebc56b29 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -24,11 +24,16 @@ import android.view.inputmethod.EditorInfo import android.webkit.SslErrorHandler import android.widget.EditText import android.widget.Toast +import androidx.activity.compose.setContent import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.R @@ -38,6 +43,7 @@ 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.components.StatusBannerRow import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.events.CertificateEvent import com.nextcloud.talk.events.RemoteWipeEvent @@ -113,6 +119,22 @@ open class BaseActivity : AppCompatActivity() { private val maintenanceModeState = MutableStateFlow(false) val maintenanceModeFlow: StateFlow = maintenanceModeState.asStateFlow() + /** + * [setContent] with the offline/maintenance-mode [StatusBannerRow] pushed above [content], + * for screens that are fully written in Compose. Chat is XML-rooted with Compose islands and + * keeps its own banner instead of using this. + */ + protected fun setContentWithStatusBanner(content: @Composable () -> Unit) { + setContent { + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + content() + } + } + } + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR 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 72db535ceb..6ed3e971cd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -11,7 +11,6 @@ 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 import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -93,7 +92,6 @@ 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.components.StatusBannerRow import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.models.json.chat.ChatUtils @@ -163,11 +161,9 @@ class ScheduledMessagesActivity : BaseActivity() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) scheduledMessagesViewModel = ViewModelProvider(this, viewModelFactory)[ScheduledMessagesViewModel::class.java] - setContent { + setContentWithStatusBanner { val colorScheme = viewThemeUtils.getColorScheme(this) val currentUser by scheduledMessagesViewModel.currentUserState.collectAsStateWithLifecycle() - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() LaunchedEffect(Unit) { scheduledMessagesViewModel.loadCurrentUser() } @@ -178,40 +174,37 @@ class ScheduledMessagesActivity : BaseActivity() { LocalShowThreadButton provides false ) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - 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) - } - ) - } + 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) + } + ) } } // CompositionLocalProvider } 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 52933550e1..e1a6f2f4f9 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -10,10 +10,7 @@ package com.nextcloud.talk.contacts import android.annotation.SuppressLint import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -21,7 +18,6 @@ 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.components.StatusBannerRow import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser @@ -40,7 +36,7 @@ class ContactsActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) contactsViewModel = ViewModelProvider(this, viewModelFactory)[ContactsViewModel::class.java] - setContent { + setContentWithStatusBanner { val isAddParticipants = intent.getBooleanExtra(BundleKeys.KEY_ADD_PARTICIPANTS, false) val hideAlreadyAddedParticipants = intent.getBooleanExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, false) contactsViewModel.getContactsFromSearchParams() @@ -64,20 +60,14 @@ class ContactsActivity : BaseActivity() { }.toSet().toMutableList() contactsViewModel.updateSelectedParticipants(selectedParticipants) - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - MaterialTheme( colorScheme = colorScheme ) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - ContactsScreen( - contactsViewModel = contactsViewModel, - uiState = uiState.value - ) - } + ContactsScreen( + contactsViewModel = contactsViewModel, + uiState = uiState.value + ) } } } 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 d0bb432d98..0bbb0f079c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -9,10 +9,8 @@ package com.nextcloud.talk.conversationinfoedit import android.os.Bundle import android.util.Log -import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.LaunchedEffect @@ -28,7 +26,6 @@ import com.github.dhaval2404.imagepicker.ImagePicker import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditCallbacks import com.nextcloud.talk.conversationinfoedit.ui.ConversationInfoEditScreen import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel @@ -103,7 +100,7 @@ class ConversationInfoEditActivity : BaseActivity() { private fun setupCompose() { val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { val uiState by conversationInfoEditViewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val userMessage = uiState.userMessage @@ -124,41 +121,35 @@ class ConversationInfoEditActivity : BaseActivity() { } } - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - ConversationInfoEditScreen( - uiState = uiState, - callbacks = ConversationInfoEditCallbacks( - onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, - onSaveClick = { conversationInfoEditViewModel.saveNameAndDescription() }, - onAvatarUploadClick = { - pickImage?.selectLocal(startImagePickerForResult = startImagePickerForResult) - }, - onAvatarChooseClick = { - pickImage?.selectRemote( - startSelectRemoteFilesIntentForResult = startSelectRemoteFilesIntentForResult - ) - }, - onAvatarCameraClick = { - pickImage?.takePicture( - startTakePictureIntentForResult = startTakePictureIntentForResult - ) - }, - onAvatarDeleteClick = { conversationInfoEditViewModel.deleteAvatar() }, - onAvatarEmojiAvatarConfirmed = { emoji, color -> - conversationInfoEditViewModel.onEmojiAvatarConfirmed(emoji, color) - }, - onNameChange = { conversationInfoEditViewModel.updateConversationName(it) }, - onDescriptionChange = { conversationInfoEditViewModel.updateConversationDescription(it) } - ), - snackbarHostState = snackbarHostState - ) - } + ConversationInfoEditScreen( + uiState = uiState, + callbacks = ConversationInfoEditCallbacks( + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onSaveClick = { conversationInfoEditViewModel.saveNameAndDescription() }, + onAvatarUploadClick = { + pickImage?.selectLocal(startImagePickerForResult = startImagePickerForResult) + }, + onAvatarChooseClick = { + pickImage?.selectRemote( + startSelectRemoteFilesIntentForResult = startSelectRemoteFilesIntentForResult + ) + }, + onAvatarCameraClick = { + pickImage?.takePicture( + startTakePictureIntentForResult = startTakePictureIntentForResult + ) + }, + onAvatarDeleteClick = { conversationInfoEditViewModel.deleteAvatar() }, + onAvatarEmojiAvatarConfirmed = { emoji, color -> + conversationInfoEditViewModel.onEmojiAvatarConfirmed(emoji, color) + }, + onNameChange = { conversationInfoEditViewModel.updateConversationName(it) }, + onDescriptionChange = { conversationInfoEditViewModel.updateConversationDescription(it) } + ), + snackbarHostState = snackbarHostState + ) } } } 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 0bc786dbc7..1f9568128d 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -101,7 +101,7 @@ class DiagnosisActivity : BaseActivity() { val useEmbeddedDistrib = UnifiedPushUtils.hasEmbeddedDistributor(context) && !useUnifiedPush val showTestPushButton = isGooglePlayServicesAvailable || useUnifiedPush || useEmbeddedDistrib - setContent { + setContentWithStatusBanner { DiagnosisScreen( colorScheme = colorScheme, networkMonitor = networkMonitor, @@ -176,26 +176,23 @@ private fun DiagnosisScreen( stringResource(R.string.nc_settings_share_report_title) to onShareReportClick ) - MaterialTheme( - colorScheme = colorScheme - ) { - val isOnline = networkMonitor.isOnline.collectAsState().value - val isMaintenanceMode = maintenanceModeFlow.collectAsState().value + MaterialTheme( + colorScheme = colorScheme + ) { + val isOnline = networkMonitor.isOnline.collectAsState().value ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - Scaffold( - modifier = Modifier - .statusBarsPadding() - .displayCutoutPadding(), - topBar = { - StandardAppBar( - title = stringResource(R.string.nc_settings_diagnosis_title), - menuItems - ) - }, - content = { paddingValues -> - val viewState = diagnosisViewModel.notificationViewState.collectAsState().value + Scaffold( + modifier = Modifier + .statusBarsPadding() + .displayCutoutPadding(), + topBar = { + StandardAppBar( + title = stringResource(R.string.nc_settings_diagnosis_title), + menuItems + ) + }, + content = { paddingValues -> + val viewState = diagnosisViewModel.notificationViewState.collectAsState().value Column( Modifier 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 2f55cf3a5c..fc831f16f7 100644 --- a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt @@ -8,19 +8,14 @@ package com.nextcloud.talk.location import android.content.Intent import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column 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.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.location.components.GeocodingScreen import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.location.viewmodels.GeoCodingViewModel @@ -61,21 +56,16 @@ class GeocodingActivity : BaseActivity() { val email = context.getString(R.string.osm_geocoder_contact) TalkJsonNominatimClient(baseUrl, okHttpClient, email) - setContent { + setContentWithStatusBanner { val colorScheme = viewThemeUtils.getColorScheme(this) - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - GeocodingScreen( - viewModel = viewModel, - onBack = { onBackPressedDispatcher.onBackPressed() }, - onAddressSelected = { address -> navigateToLocationPicker(address) } - ) - } + GeocodingScreen( + viewModel = viewModel, + onBack = { onBackPressedDispatcher.onBackPressed() }, + onAddressSelected = { address -> navigateToLocationPicker(address) } + ) } } } 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 0c6bf21907..8fa37d2ab0 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -9,19 +9,14 @@ package com.nextcloud.talk.location import android.content.Intent import android.os.Bundle import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column 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.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.extensions.getParcelableExtraProvider import com.nextcloud.talk.location.components.LocationPickerScreen import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION @@ -102,21 +97,15 @@ class LocationPickerActivity : BaseActivity() { onBackPressedDispatcher.addCallback(this, onBackPressedCallback) val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - + setContentWithStatusBanner { MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - LocationPickerScreen( - viewModel = viewModel, - onSearchClick = { navigateToGeocoding() }, - onBack = { onBackPressedDispatcher.onBackPressed() }, - onFinish = { finish() } - ) - } + LocationPickerScreen( + viewModel = viewModel, + onSearchClick = { navigateToGeocoding() }, + onBack = { onBackPressedDispatcher.onBackPressed() }, + onFinish = { finish() } + ) } } } 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 993317bf9a..d342a9555a 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 @@ -105,7 +105,7 @@ class LogsActivity : BaseActivity() { val viewModel = ViewModelProvider(this, viewModelFactory)[LogsViewModel::class.java] val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { MaterialTheme(colorScheme = colorScheme) { val entries = viewModel.entries.collectAsState().value val isLoading = viewModel.isLoading.collectAsState().value @@ -126,38 +126,32 @@ class LogsActivity : BaseActivity() { } ) - val isOnline by networkMonitor.isOnline.collectAsState() - val isMaintenanceMode by maintenanceModeFlow.collectAsState() - ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - Scaffold( - modifier = Modifier - .statusBarsPadding() - .displayCutoutPadding(), - topBar = { - StandardAppBar( - title = stringResource(R.string.nc_logs_title), - menuItems = menuItems - ) - } - ) { paddingValues -> - LogsContent( - state = LogsUiState( - entries = entries, - isLoading = isLoading, - totalSize = totalSize, - lostEntries = viewModel.lostEntries, - loggingEnabled = loggingEnabled, - advancedLogging = advancedLogging - ), - onLoggingEnabledChange = { viewModel.setLoggingEnabled(it) }, - onAdvancedLoggingChange = { viewModel.setAdvancedLogging(it) }, - onDisable = { deleteExisting -> viewModel.setLoggingEnabled(false, deleteExisting) }, - paddingValues = paddingValues + Scaffold( + modifier = Modifier + .statusBarsPadding() + .displayCutoutPadding(), + topBar = { + StandardAppBar( + title = stringResource(R.string.nc_logs_title), + menuItems = menuItems ) } + ) { paddingValues -> + LogsContent( + state = LogsUiState( + entries = entries, + isLoading = isLoading, + totalSize = totalSize, + lostEntries = viewModel.lostEntries, + loggingEnabled = loggingEnabled, + advancedLogging = advancedLogging + ), + onLoggingEnabledChange = { viewModel.setLoggingEnabled(it) }, + onAdvancedLoggingChange = { viewModel.setAdvancedLogging(it) }, + onDisable = { deleteExisting -> viewModel.setLoggingEnabled(false, deleteExisting) }, + paddingValues = paddingValues + ) } LaunchedEffect(Unit) { 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 3dd4d1b4a6..639bcc4f65 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -8,8 +8,6 @@ package com.nextcloud.talk.openconversations import android.content.Intent import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.lifecycle.ViewModelProvider @@ -19,7 +17,6 @@ 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.components.StatusBannerRow import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.utils.adjustUIForAPILevel35 @@ -44,32 +41,26 @@ class ListOpenConversationsActivity : BaseActivity() { val user = currentUserProviderOld.currentUser.blockingGet() - setContent { + setContentWithStatusBanner { val colorScheme = viewThemeUtils.getColorScheme(this) val viewState by openConversationsViewModel.viewState.collectAsStateWithLifecycle() val searchTerm by openConversationsViewModel.searchTerm.collectAsStateWithLifecycle() - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - OpenConversationsScreen( - viewState = viewState, - searchTerm = searchTerm, - userBaseUrl = user?.baseUrl, - listenerInput = OpenConversationsScreenListenerInput( - onSearchTermChange = { term -> - openConversationsViewModel.updateSearchTerm(term) - openConversationsViewModel.fetchConversations() - }, - onConversationClick = { conversation -> navigateToChat(conversation) }, - onBackClick = { onBackPressedDispatcher.onBackPressed() } - ) + OpenConversationsScreen( + viewState = viewState, + searchTerm = searchTerm, + userBaseUrl = user?.baseUrl, + listenerInput = OpenConversationsScreenListenerInput( + onSearchTermChange = { term -> + openConversationsViewModel.updateSearchTerm(term) + openConversationsViewModel.fetchConversations() + }, + onConversationClick = { conversation -> navigateToChat(conversation) }, + onBackClick = { onBackPressedDispatcher.onBackPressed() } ) - } + ) } } } 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 2e0fdb3046..bfc7827c84 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -10,18 +10,15 @@ import android.app.Activity import android.content.pm.PackageManager import android.os.Bundle import android.util.Log -import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toFile import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.github.dhaval2404.imagepicker.ImagePicker import com.github.dhaval2404.imagepicker.ImagePicker.Companion.getError @@ -31,7 +28,6 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.userprofile.Scope @@ -130,25 +126,19 @@ class ProfileActivity : BaseActivity() { } val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - + setContentWithStatusBanner { MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - ProfileScreen( - state = profileUiState, - callbacks = buildProfileCallbacks() + ProfileScreen( + state = profileUiState, + callbacks = buildProfileCallbacks() + ) + scopeSheetRequest?.let { req -> + ScopeModalBottomSheet( + showPrivate = req.field != Field.DISPLAYNAME && req.field != Field.EMAIL, + onScopeSelected = { scope -> updateItemScope(req.position, scope) }, + onDismiss = { scopeSheetRequest = null } ) - scopeSheetRequest?.let { req -> - ScopeModalBottomSheet( - showPrivate = req.field != Field.DISPLAYNAME && req.field != Field.EMAIL, - onScopeSelected = { scope -> updateItemScope(req.position, scope) }, - onDismiss = { scopeSheetRequest = null } - ) - } } } } 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 09580f0a03..46224447a1 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -11,7 +11,6 @@ import android.content.Intent 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 import androidx.compose.foundation.layout.Box @@ -38,7 +37,6 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity @@ -48,7 +46,6 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.ChatActivity.Companion.TAG import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.models.json.threads.ThreadInfo import com.nextcloud.talk.threadsoverview.components.ThreadRow @@ -89,45 +86,40 @@ class ThreadsOverviewActivity : BaseActivity() { threadsSourceUrl = extras?.getString(KEY_THREADS_SOURCE_URL).orEmpty() appbarTitle = extras?.getString(KEY_APPBAR_TITLE).orEmpty() - setContent { + setContentWithStatusBanner { val backgroundColor = colorResource(id = R.color.bg_default) - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() MaterialTheme( colorScheme = colorScheme ) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - Scaffold( - modifier = Modifier - .statusBarsPadding(), - topBar = { - StandardAppBar( - title = appbarTitle, - null - ) - }, - content = { paddingValues -> - val uiState by threadsOverviewViewModel.threadsListState.collectAsState() + Scaffold( + modifier = Modifier + .statusBarsPadding(), + topBar = { + StandardAppBar( + title = appbarTitle, + null + ) + }, + content = { paddingValues -> + val uiState by threadsOverviewViewModel.threadsListState.collectAsState() - Column( - Modifier - .padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp) - .background(backgroundColor) - .fillMaxSize() - ) { - ThreadsOverviewScreen( - uiState, - onThreadClick = { roomToken, threadId -> - navigateToChatActivity(roomToken, threadId) - } - ) - } + Column( + Modifier + .padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp) + .background(backgroundColor) + .fillMaxSize() + ) { + ThreadsOverviewScreen( + uiState, + onThreadClick = { roomToken, threadId -> + navigateToChatActivity(roomToken, threadId) + } + ) } - ) - } + } + ) } } } From 5551459efba2b41606d6d7fc0c81ea5b0a59011b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:33:40 +0200 Subject: [PATCH 07/10] fix(conv-list): remove double status-bar gap when the banner is visible ConversationListTopBar always applied statusBarsPadding regardless of whether StatusBannerRow above it was already visible and self-padding for the status bar, producing a visible gap between the banner and the search/top bar. Only pad the top bar when the banner is hidden. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationlist/ui/ConversationsListScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index e144c4bcde..43ddd2fc93 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -248,6 +248,7 @@ fun ConversationsListScreen( Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { + val showBanner = !isOnline || isMaintenanceMode Column(modifier = Modifier.fillMaxWidth()) { StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) ConversationListTopBar( @@ -273,7 +274,7 @@ fun ConversationsListScreen( onNavigateBack = callbacks.onNavigateBack, onAccountChooserClick = callbacks.onAccountChooserClick ), - modifier = Modifier.statusBarsPadding() + modifier = if (showBanner) Modifier else Modifier.statusBarsPadding() ) } } From ff17960e039d1c12ff8a165200b6907fb9f9d5f6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:57:18 +0200 Subject: [PATCH 08/10] fix(ui): stop the status banner doubling screens' own status-bar padding Several screens apply their own statusBarsPadding()/Scaffold inset (ThreadsOverviewActivity, DiagnosisActivity, LogsActivity, ConversationCreationActivity, ContactsScreen, LocationPickerScreen) assuming they sit at the true top of the window. Once StatusBannerRow is visible it already claims that inset for itself, so those screens' own padding added a second, redundant gap on top of it (e.g. an oversized app bar in "new conversation"). setContentWithStatusBanner now marks the status-bar inset consumed for content() whenever the banner is showing, so screens' existing statusBarsPadding() calls add nothing extra in that case. Fixed once centrally since some of the affected composables (ContactsScreen, LocationPickerScreen) have no access to the banner state to guard against it themselves. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/BaseActivity.kt | 27 +++++++++++++++++-- 1 file changed, 25 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 20ebc56b29..88dcf446be 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -27,9 +27,15 @@ import android.widget.Toast import androidx.activity.compose.setContent import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -128,9 +134,26 @@ open class BaseActivity : AppCompatActivity() { setContent { val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - Column { + val showBanner = !isOnline || isMaintenanceMode + Column(modifier = Modifier.fillMaxSize()) { StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - content() + // content() itself emits bare sibling composables (e.g. ColoredStatusBar() next to + // the screen), which rely on being at the composition root to overlay rather than + // stack. Confining them to a single weighted Box here preserves that overlay + // behavior while still reserving exactly the space below the banner for them. + // + // Several screens also apply their own statusBarsPadding()/Scaffold insets that + // assume they sit at the true top of the window. Once the banner is visible it has + // already claimed that inset (StatusBannerRow pads itself for it), so mark it + // consumed here — any statusBarsPadding() further down then adds nothing extra, + // avoiding a doubled gap under the banner. + Box( + modifier = Modifier + .weight(1f) + .let { if (showBanner) it.consumeWindowInsets(WindowInsets.statusBars) else it } + ) { + content() + } } } } From ac03f8222b945d23d60d84e02165b393653f1b58 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 17:58:27 +0200 Subject: [PATCH 09/10] fix(ui): show status banner on conversation info screen ConversationInfoActivity is fully Compose like its sibling ConversationInfoEditActivity, but was missed when the status banner was rolled out to Compose screens. Switch it to BaseActivity.setContentWithStatusBanner. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/conversationinfo/ConversationInfoActivity.kt | 3 +-- 1 file changed, 1 insertion(+), 2 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 b38485bfd8..b856a9993c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -10,7 +10,6 @@ import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.util.Log -import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes @@ -229,7 +228,7 @@ class ConversationInfoActivity : BaseActivity() { private fun setupCompose() { val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } From ca2de477078db8dca0e613e807a364552f0cc0f1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 19:23:18 +0200 Subject: [PATCH 10/10] fix(ui): move chat's status banner to the top of the screen ChatActivity showed its own offline/maintenance-mode banner above the message input panel, separate from the shared StatusBannerRow used by every other screen. Add a statusBannerComposeView above the toolbar and wire it to the shared component, and drop the now-redundant banner views and visibility-toggling code from the message input fragment, keeping its online/offline-driven button visibility and reconnect-resend logic intact. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 12 +++++ .../talk/chat/MessageInputFragment.kt | 49 +++---------------- app/src/main/res/layout/activity_chat.xml | 5 ++ .../res/layout/fragment_message_input.xml | 22 --------- 4 files changed, 23 insertions(+), 65 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 066c8dc647..555d85789f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -127,6 +127,7 @@ import com.nextcloud.talk.chat.ui.buildMessageActionsState import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity @@ -593,6 +594,7 @@ class ChatActivity : binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) + setupStatusBannerView() setupChatToolbarView() setupChatEmptyStateView() setupTypingIndicatorView() @@ -2075,6 +2077,16 @@ class ChatActivity : updateToolbarState() } + private fun setupStatusBannerView() { + binding.statusBannerComposeView.setContent { + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by chatViewModel.maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(this@ChatActivity)) { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + } + } + } + private fun setupChatToolbarView() { binding.chatToolbarComposeView.setContent { MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(this@ChatActivity)) { 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 a1207a93b9..ec282c1757 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -27,7 +27,6 @@ import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.Animation -import android.view.animation.Animation.AnimationListener import android.view.animation.LinearInterpolator import android.view.inputmethod.InputMethodManager import android.widget.Chronometer @@ -296,12 +295,12 @@ class MessageInputFragment : Fragment() { } viewLifecycleOwner.lifecycleScope.launch { - var wasOnline: Boolean + var previousOnline: Boolean? = null networkMonitor.isOnline .onEach { isOnline -> - wasOnline = !binding.fragmentConnectionLost.isShown - val connectionGained = (!wasOnline && isOnline) - Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained") + val connectionGained = (previousOnline == false && isOnline) + previousOnline = isOnline + Log.d(TAG, "isOnline: $isOnline\nconnectionGained: $connectionGained") if (connectionGained) { messageInputViewModel.sendUnsentMessages( chatActivity.conversationUser!!.getCredentials(), @@ -312,7 +311,7 @@ class MessageInputFragment : Fragment() { ) ) } - handleUI(isOnline, connectionGained) + handleUI(isOnline) }.collect() } @@ -331,12 +330,6 @@ class MessageInputFragment : Fragment() { binding.fragmentCallStarted.visibility = if (hasCall) View.VISIBLE else View.GONE } } - - viewLifecycleOwner.lifecycleScope.launch { - chatActivity.chatViewModel.maintenanceModeFlow.collect { isMaintenanceMode -> - binding.fragmentMaintenanceMode.visibility = if (isMaintenanceMode) View.VISIBLE else View.GONE - } - } } private fun setReactionsOnly(spreedCapabilities: SpreedCapability) { @@ -350,33 +343,8 @@ class MessageInputFragment : Fragment() { } } - private fun handleUI(isOnline: Boolean, connectionGained: Boolean) { + private fun handleUI(isOnline: Boolean) { if (isOnline) { - if (connectionGained) { - val animation: Animation = AlphaAnimation(FULLY_OPAQUE, FULLY_TRANSPARENT) - animation.duration = CONNECTION_ESTABLISHED_ANIM_DURATION - animation.interpolator = LinearInterpolator() - binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityGreen)) - binding.fragmentConnectionLost.text = getString(R.string.connection_established) - binding.fragmentConnectionLost.startAnimation(animation) - binding.fragmentConnectionLost.animation.setAnimationListener(object : AnimationListener { - override fun onAnimationStart(animation: Animation?) { - // unused atm - } - - override fun onAnimationEnd(animation: Animation?) { - binding.fragmentConnectionLost.visibility = View.GONE - binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityRed)) - binding.fragmentConnectionLost.text = - getString(R.string.connection_lost_sent_messages_are_queued) - } - - override fun onAnimationRepeat(animation: Animation?) { - // unused atm - } - }) - } - binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE binding.fragmentMessageInputView.recordAudioButton.visibility = if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) View.VISIBLE else View.GONE @@ -388,10 +356,6 @@ class MessageInputFragment : Fragment() { } else { binding.fragmentMessageInputView.attachmentButton.visibility = View.INVISIBLE binding.fragmentMessageInputView.recordAudioButton.visibility = View.INVISIBLE - binding.fragmentConnectionLost.clearAnimation() - binding.fragmentConnectionLost.visibility = View.GONE - binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityRed)) - binding.fragmentConnectionLost.visibility = View.VISIBLE binding.fragmentMessageInputView.scheduledMessagesButton.visibility = View.GONE } } @@ -1288,7 +1252,6 @@ class MessageInputFragment : Fragment() { private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f private const val INCREMENT = 8f private const val CURSOR_KEY = "_cursor" - private const val CONNECTION_ESTABLISHED_ANIM_DURATION: Long = 3000 private const val FULLY_OPAQUE: Float = 1.0f private const val FULLY_TRANSPARENT: Float = 0.0f private const val OPACITY_DISABLED = 0.7f diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 16a10d70a5..787bbef873 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -17,6 +17,11 @@ android:orientation="vertical" tools:ignore="Overdraw"> + + - - - -