From cff73a52355dbfba134112006426ec85638bfdc5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 11:25:39 +0200 Subject: [PATCH 1/8] 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 816a9d760d7..5e2ac4af43d 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -300,6 +300,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 c50c84e8aad..a148fa809b5 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 99566ff5543..d67bf8be976 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 @@ -998,6 +1018,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 35ae48ee462..8d3233d0ceb 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 50e5c29a0bb..daef6c8a3e7 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 7e9ee3ca0e6..84f909db1f4 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 @@ -405,6 +405,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 189eb5f845c..57bf99a7f6b 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 2/8] 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 8468cbe90ee..cc329212dc9 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 @@ -22,6 +22,13 @@ interface OfflineConversationsRepository { */ val roomListFlow: 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 055f248b767..afdba2ee22a 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 @@ -82,6 +82,10 @@ class OfflineFirstConversationsRepository @Inject constructor( get() = _conversationFlow private val _conversationFlow: MutableSharedFlow = MutableSharedFlow() + override val getRoomsErrorFlow: Flow + get() = _getRoomsErrorFlow + private val _getRoomsErrorFlow: MutableSharedFlow = MutableSharedFlow() + private val scope = CoroutineScope(Dispatchers.IO) sealed interface ConversationResult { @@ -201,6 +205,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) } return conversationsFromSync } 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 f890e3f730c..ff1605735c8 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 @@ -166,6 +167,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 824f433b04597f6d31eb409c90d435a93769aea6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 12:51:32 +0200 Subject: [PATCH 3/8] 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 ++++--------- .../data/OfflineConversationsRepository.kt | 7 -- .../OfflineFirstConversationsRepository.kt | 5 -- .../viewmodels/ConversationsListViewModel.kt | 9 --- .../talk/dagger/modules/RestModule.java | 9 +++ .../talk/events/ServerStatusEvent.kt | 17 +++++ .../talk/utils/HttpStatusInterceptor.kt | 65 +++++++++++++++++++ 13 files changed, 120 insertions(+), 88 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 9e1b0aa4829..cb041e7cabe 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 @@ -3999,6 +4001,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 a148fa809b5..c50c84e8aad 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 d67bf8be976..99566ff5543 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 @@ -1018,8 +998,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 8d3233d0ceb..35ae48ee462 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 daef6c8a3e7..50e5c29a0bb 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 84f909db1f4..1d8fae4fa18 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 @@ -96,6 +96,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 @@ -405,7 +406,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 21e143ee0a6..2b71224c46e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -61,6 +61,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 @@ -118,7 +120,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import retrofit2.HttpException import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -201,7 +202,6 @@ class ConversationsListActivity : BaseActivity() { } else { currentUserProviderOld.currentUser.blockingGet() } - conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] @@ -450,7 +450,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 -> {} @@ -669,22 +670,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 } } @@ -1424,14 +1418,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)) @@ -1530,12 +1516,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/data/OfflineConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt index cc329212dc9..8468cbe90ee 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 @@ -22,13 +22,6 @@ interface OfflineConversationsRepository { */ val roomListFlow: 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 afdba2ee22a..055f248b767 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 @@ -82,10 +82,6 @@ class OfflineFirstConversationsRepository @Inject constructor( get() = _conversationFlow private val _conversationFlow: MutableSharedFlow = MutableSharedFlow() - override val getRoomsErrorFlow: Flow - get() = _getRoomsErrorFlow - private val _getRoomsErrorFlow: MutableSharedFlow = MutableSharedFlow() - private val scope = CoroutineScope(Dispatchers.IO) sealed interface ConversationResult { @@ -205,7 +201,6 @@ 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) } return conversationsFromSync } 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 ff1605735c8..f890e3f730c 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 @@ -167,14 +166,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 c98fd2e82ff..e4e37648979 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 00000000000..2d9ffe3d5e6 --- /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 00000000000..67f2b8953af --- /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 0bb9394102db30bfe7d2d7554430893a0c42fbfb Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 12:53:45 +0200 Subject: [PATCH 4/8] 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 cb041e7cabe..9ab4d323a19 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -195,6 +195,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 @@ -289,6 +290,9 @@ class ChatActivity : @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var httpStatusInterceptor: HttpStatusInterceptor + @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory @@ -619,6 +623,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 2b71224c46e..61e64704de5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -89,6 +89,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 @@ -143,6 +144,9 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var httpStatusInterceptor: HttpStatusInterceptor + @Inject lateinit var contactsViewModel: ContactsViewModel @@ -202,6 +206,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 67f2b8953af..e3e7f0af9c6 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 30c0731f370c779d653c66a7d33abb3f69df339a Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:46:20 +0200 Subject: [PATCH 5/8] 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 | 9 +- .../ConversationInfoActivity.kt | 16 +++- .../ConversationInfoEditActivity.kt | 60 ++++++++------ .../ConversationsListActivity.kt | 26 ++---- .../ui/ConversationsListScreen.kt | 6 +- .../talk/diagnosis/DiagnosisActivity.kt | 83 ++++++++++--------- .../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 +++++++------ 19 files changed, 374 insertions(+), 253 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 35d2c1e1ffd..a264255e148 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 366c7989b2b..5f3a14aff81 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 9ab4d323a19..33a99fbb598 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 @@ -195,7 +194,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 @@ -287,12 +285,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 @@ -4011,7 +4003,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 6f6f14708f0..a94bd8d8e99 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 6aec84d2351..1043d98901d 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 e20ee7b61b2..f41a4b07cf3 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 @@ -62,14 +65,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 0638284ba94..e345fb60017 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -89,6 +89,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import coil.compose.AsyncImage import com.nextcloud.talk.R @@ -99,6 +100,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.viewmodel.ConversationCreationViewModel @@ -131,10 +133,15 @@ class ConversationCreationActivity : BaseActivity() { setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() MaterialTheme( colorScheme = colorScheme ) { - ConversationCreationScreen(conversationCreationViewModel, context, pickImage) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + ConversationCreationScreen(conversationCreationViewModel, context, pickImage) + } } } } 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 6bd495101f8..28225534236 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.LaunchedEffect @@ -44,6 +45,7 @@ import com.nextcloud.talk.api.NcApi 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.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.conversationinfo.model.ParticipantModel @@ -239,12 +241,18 @@ class ConversationInfoActivity : BaseActivity() { } } + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - ConversationInfoScreen( - state = uiState, - callbacks = buildCallbacks() - ) + Column { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + ConversationInfoScreen( + state = uiState, + callbacks = buildCallbacks() + ) + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index 529728d8904..d0bb432d989 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 61e64704de5..2ab35b2ec5a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -57,7 +57,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 @@ -89,7 +88,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 @@ -141,12 +139,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 @@ -156,7 +148,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) @@ -206,10 +197,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] @@ -236,6 +223,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) @@ -257,7 +246,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, @@ -282,7 +271,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() @@ -680,14 +668,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 3338f341443..b730117a49e 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 @@ -246,7 +247,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( @@ -270,7 +271,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 aeef186e0b3..6a3712add3f 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -34,7 +34,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -57,9 +57,6 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -93,46 +90,50 @@ class DiagnosisActivity : BaseActivity() { colorScheme = colorScheme ) { val isOnline = networkMonitor.isOnline.collectAsState().value + val isMaintenanceMode = maintenanceModeFlow.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 - - Column( - Modifier - .background(backgroundColor) - .padding( - 0.dp, - paddingValues.calculateTopPadding(), - 0.dp, - paddingValues.calculateBottomPadding() - ) - .fillMaxSize() - ) { - DiagnosisContentComposable( - diagnosisDataState, - isLoading = diagnosisViewModel.isLoading.value, - showDialog = diagnosisViewModel.showDialog.value, - viewState = viewState, - onTestPushClick = { diagnosisViewModel.fetchTestPushResult() }, - onDismissDialog = { diagnosisViewModel.dismissDialog() }, - showTestPushButton = isGooglePlayServicesAvailable || - useUnifiedPush || - useEmbeddedDistrib, - isOnline = isOnline + 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 + .background(backgroundColor) + .padding( + 0.dp, + paddingValues.calculateTopPadding(), + 0.dp, + paddingValues.calculateBottomPadding() + ) + .fillMaxSize() + ) { + DiagnosisContentComposable( + diagnosisDataState, + isLoading = diagnosisViewModel.isLoading.value, + showDialog = diagnosisViewModel.showDialog.value, + viewState = viewState, + onTestPushClick = { diagnosisViewModel.fetchTestPushResult() }, + onDismissDialog = { diagnosisViewModel.dismissDialog() }, + showTestPushButton = isGooglePlayServicesAvailable || + useUnifiedPush || + useEmbeddedDistrib, + isOnline = isOnline + ) + } } - } - ) + ) + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt index b8a1e019f2d..2f55cf3a5c5 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 80b3aaa0b1f..0c6bf219075 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 3b8cb511a0b..44de87df17a 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 @@ -58,6 +58,7 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.Level import com.nextcloud.talk.logger.LogEntry import java.text.SimpleDateFormat @@ -89,32 +90,38 @@ class LogsActivity : BaseActivity() { stringResource(R.string.nc_logs_delete_all) to { viewModel.deleteAll() } ) + 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 3b91c1b1804..3dd4d1b4a65 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 df36d6bbf37..2e0fdb30462 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 66a2ff0a8fe..e70e2b4851e 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 @@ -129,9 +128,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 eabb185de20..09580f0a03c 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 2ca6b6c939b39f62e3f8a7349a88bcd50f935626 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:29:09 +0200 Subject: [PATCH 6/8] 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 ++--- .../ConversationCreationActivity.kt | 12 +-- .../ConversationInfoActivity.kt | 19 ++--- .../ConversationInfoEditActivity.kt | 65 +++++++-------- .../talk/diagnosis/DiagnosisActivity.kt | 82 +++++++++---------- .../talk/location/GeocodingActivity.kt | 22 ++--- .../talk/location/LocationPickerActivity.kt | 25 ++---- .../nextcloud/talk/logger/ui/LogsActivity.kt | 56 ++++++------- .../ListOpenConversationsActivity.kt | 35 +++----- .../nextcloud/talk/profile/ProfileActivity.kt | 30 +++---- .../ThreadsOverviewActivity.kt | 60 ++++++-------- 13 files changed, 218 insertions(+), 301 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 5f3a14aff81..20ebc56b295 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 a94bd8d8e99..fdfccee6638 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 f41a4b07cf3..36789c271d9 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() @@ -65,20 +61,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/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index e345fb60017..421650d02d7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -17,7 +17,6 @@ import android.net.Uri import android.os.Bundle import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -89,7 +88,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import coil.compose.AsyncImage import com.nextcloud.talk.R @@ -100,7 +98,6 @@ 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.viewmodel.ConversationCreationViewModel @@ -130,18 +127,13 @@ class ConversationCreationActivity : BaseActivity() { val conversationUser = conversationCreationViewModel.currentUser pickImage = PickImage(this, conversationUser) - setContent { + setContentWithStatusBanner { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() MaterialTheme( colorScheme = colorScheme ) { - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - ConversationCreationScreen(conversationCreationViewModel, context, pickImage) - } + ConversationCreationScreen(conversationCreationViewModel, context, pickImage) } } } 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 28225534236..81c6f21616e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -11,11 +11,9 @@ import android.content.Intent import android.os.Bundle import android.util.Log import android.view.LayoutInflater -import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.LaunchedEffect @@ -45,7 +43,6 @@ import com.nextcloud.talk.api.NcApi 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.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.conversationinfo.model.ParticipantModel @@ -216,7 +213,7 @@ class ConversationInfoActivity : BaseActivity() { private fun setupCompose() { val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } @@ -241,18 +238,12 @@ class ConversationInfoActivity : BaseActivity() { } } - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by maintenanceModeFlow.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - Column { - StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) - ConversationInfoScreen( - state = uiState, - callbacks = buildCallbacks() - ) - } + ConversationInfoScreen( + state = uiState, + callbacks = buildCallbacks() + ) } } } 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 d0bb432d989..0bbb0f079c7 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 6a3712add3f..af1714bbe23 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -10,7 +10,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.os.Bundle import android.widget.Toast -import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.displayCutoutPadding @@ -34,7 +33,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -79,7 +77,7 @@ class DiagnosisActivity : BaseActivity() { val useUnifiedPush = appPreferences.useUnifiedPush val useEmbeddedDistrib = UnifiedPushUtils.hasEmbeddedDistributor(context) && !useUnifiedPush - setContent { + setContentWithStatusBanner { val backgroundColor = colorResource(id = R.color.bg_default) val menuItems = listOf( @@ -90,50 +88,46 @@ class DiagnosisActivity : BaseActivity() { colorScheme = colorScheme ) { val isOnline = networkMonitor.isOnline.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 - .background(backgroundColor) - .padding( - 0.dp, - paddingValues.calculateTopPadding(), - 0.dp, - paddingValues.calculateBottomPadding() - ) - .fillMaxSize() - ) { - DiagnosisContentComposable( - diagnosisDataState, - isLoading = diagnosisViewModel.isLoading.value, - showDialog = diagnosisViewModel.showDialog.value, - viewState = viewState, - onTestPushClick = { diagnosisViewModel.fetchTestPushResult() }, - onDismissDialog = { diagnosisViewModel.dismissDialog() }, - showTestPushButton = isGooglePlayServicesAvailable || - useUnifiedPush || - useEmbeddedDistrib, - isOnline = isOnline + 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 + .background(backgroundColor) + .padding( + 0.dp, + paddingValues.calculateTopPadding(), + 0.dp, + paddingValues.calculateBottomPadding() ) - } + .fillMaxSize() + ) { + DiagnosisContentComposable( + diagnosisDataState, + isLoading = diagnosisViewModel.isLoading.value, + showDialog = diagnosisViewModel.showDialog.value, + viewState = viewState, + onTestPushClick = { diagnosisViewModel.fetchTestPushResult() }, + onDismissDialog = { diagnosisViewModel.dismissDialog() }, + showTestPushButton = isGooglePlayServicesAvailable || + useUnifiedPush || + useEmbeddedDistrib, + isOnline = isOnline + ) } - ) - } + } + ) } } } 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 2f55cf3a5c5..fc831f16f7d 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 0c6bf219075..8fa37d2ab05 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 44de87df17a..da2ac451715 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 @@ -7,7 +7,6 @@ package com.nextcloud.talk.logger.ui import android.os.Bundle -import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -58,7 +57,6 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.Level import com.nextcloud.talk.logger.LogEntry import java.text.SimpleDateFormat @@ -78,7 +76,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 @@ -90,38 +88,32 @@ class LogsActivity : BaseActivity() { stringResource(R.string.nc_logs_delete_all) to { viewModel.deleteAll() } ) - 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 3dd4d1b4a65..639bcc4f659 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 2e0fdb30462..bfc7827c840 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 09580f0a03c..46224447a12 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 2902e80e8e2fbda9c981013f1de926c48d4f6092 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:33:40 +0200 Subject: [PATCH 7/8] 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 b730117a49e..84fe6ec059e 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 @@ -247,6 +247,7 @@ fun ConversationsListScreen( Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { + val showBanner = !isOnline || isMaintenanceMode Column(modifier = Modifier.fillMaxWidth()) { StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) ConversationListTopBar( @@ -272,7 +273,7 @@ fun ConversationsListScreen( onNavigateBack = callbacks.onNavigateBack, onAccountChooserClick = callbacks.onAccountChooserClick ), - modifier = Modifier.statusBarsPadding() + modifier = if (showBanner) Modifier else Modifier.statusBarsPadding() ) } }, From d2310760b5c6aeeab5d2341b02140a0dad2349a5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:57:18 +0200 Subject: [PATCH 8/8] 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 20ebc56b295..88dcf446bee 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() + } } } }