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..88dcf446bee 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,22 @@ 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.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 +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.R @@ -38,9 +49,15 @@ 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 +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 +71,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 +116,48 @@ 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() + + /** + * [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() + val showBanner = !isOnline || isMaintenanceMode + Column(modifier = Modifier.fillMaxSize()) { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + // 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() + } + } + } + } + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR @@ -136,6 +198,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 7e17316b0df..630026489a9 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -132,9 +132,10 @@ 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 +import com.nextcloud.talk.events.ServerStatusEvent import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent import com.nextcloud.talk.jobs.DeleteConversationWorker @@ -287,9 +288,6 @@ class ChatActivity : @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory @@ -620,6 +618,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) @@ -4030,6 +4033,13 @@ class ChatActivity : } } + @Subscribe(threadMode = ThreadMode.MAIN) + override fun onServerStatusEvent(event: ServerStatusEvent) { + super.onServerStatusEvent(event) + 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/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index b542b984499..e1424a6a597 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -308,6 +308,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/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index fa18d31a9cd..6ed3e971cdd 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.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.models.json.chat.ChatUtils @@ -134,9 +132,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() } @@ -166,7 +161,7 @@ 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() LaunchedEffect(Unit) { 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 fe4c48e6b1b..523e6007ed7 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -105,6 +105,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -558,6 +559,13 @@ class ChatViewModel @AssistedInject constructor( val isLoadingFlow = chatRepository.isLoadingFlow + private val _maintenanceModeFlow = MutableStateFlow(false) + val maintenanceModeFlow: StateFlow = _maintenanceModeFlow.asStateFlow() + + fun setMaintenanceMode(active: Boolean) { + _maintenanceModeFlow.value = active + } + sealed interface ViewState object GetReminderStartState : ViewState 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..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,7 +10,6 @@ package com.nextcloud.talk.contacts import android.annotation.SuppressLint import android.os.Bundle -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember import androidx.lifecycle.ViewModelProvider @@ -37,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() 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..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 @@ -128,7 +127,7 @@ class ConversationCreationActivity : BaseActivity() { val conversationUser = conversationCreationViewModel.currentUser pickImage = PickImage(this, conversationUser) - setContent { + setContentWithStatusBanner { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current MaterialTheme( 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 bddda18c82e..f1b7f98fdc1 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -11,7 +11,6 @@ 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 @@ -214,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() } 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..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,7 +9,6 @@ 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.material3.MaterialTheme @@ -101,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 @@ -138,7 +137,9 @@ class ConversationInfoEditActivity : BaseActivity() { ) }, onAvatarCameraClick = { - pickImage?.takePicture(startTakePictureIntentForResult = startTakePictureIntentForResult) + pickImage?.takePicture( + startTakePictureIntentForResult = startTakePictureIntentForResult + ) }, onAvatarDeleteClick = { conversationInfoEditViewModel.deleteAvatar() }, onAvatarEmojiAvatarConfirmed = { emoji, color -> 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 059bba60338..6e0647c8b54 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -59,10 +59,11 @@ 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 +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 @@ -120,7 +121,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 @@ -141,9 +141,6 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var contactsViewModel: ContactsViewModel @@ -153,7 +150,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) @@ -229,6 +225,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) @@ -250,7 +248,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, @@ -275,7 +273,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() @@ -452,7 +449,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 -> {} @@ -671,22 +669,17 @@ 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() - } + @Subscribe(threadMode = ThreadMode.MAIN) + 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() + else -> { + // MAINTENANCE_MODE / OK are handled by BaseActivity's maintenanceModeFlow } - } else { - Log.e(TAG, "Exception in ConversationListActivity", throwable) - showErrorDialog() } } @@ -1450,14 +1443,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)) @@ -1556,12 +1541,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 72d9efe6131..e36b8ed1aad 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt @@ -31,6 +31,13 @@ interface OfflineConversationsRepository { */ val syncErrorFlow: Flow + /** + * Network/HTTP failures from [getRooms]'s background sync (e.g. 401, 426, 503 with the + * maintenance-mode header). [roomListFlow] only observes the local database and never fails + * from a network error, so the UI needs this separate stream to react to them. + */ + val getRoomsErrorFlow: Flow + /** * Stream of a single conversation, for use in each conversations settings. */ diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index 1c2bf927cff..90a66caff68 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -88,6 +88,10 @@ class OfflineFirstConversationsRepository @Inject constructor( get() = _syncErrorFlow private val _syncErrorFlow: MutableSharedFlow = MutableSharedFlow() + override val getRoomsErrorFlow: Flow + get() = _getRoomsErrorFlow + private val _getRoomsErrorFlow: MutableSharedFlow = MutableSharedFlow() + private val scope = CoroutineScope(Dispatchers.IO) sealed interface ConversationResult { @@ -217,6 +221,7 @@ class OfflineFirstConversationsRepository @Inject constructor( scope.launch { catchUpRoomsWithNewMessages(user, roomsWithNewMessages) } } catch (e: Exception) { Log.e(TAG, "Something went wrong when fetching conversations", e) + _getRoomsErrorFlow.emit(e) val hasCachedConversations = dao.getConversationsForUser(user.id!!).first().isNotEmpty() if (!hasCachedConversations) { _syncErrorFlow.emit(e) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index a726cb35b92..43ddd2fc93b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.talk.R import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.data.user.model.User @@ -247,7 +248,8 @@ fun ConversationsListScreen( Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { - Column(modifier = Modifier.fillMaxWidth().statusBarsPadding()) { + val showBanner = !isOnline || isMaintenanceMode + Column(modifier = Modifier.fillMaxWidth()) { StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) ConversationListTopBar( state = ConversationListTopBarState( @@ -271,7 +273,8 @@ fun ConversationsListScreen( onAvatarClick = callbacks.onAvatarClick, onNavigateBack = callbacks.onNavigateBack, onAccountChooserClick = callbacks.onAccountChooserClick - ) + ), + modifier = if (showBanner) Modifier else Modifier.statusBarsPadding() ) } } 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/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index ebd4d0c0ed2..1f9568128d4 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -41,6 +41,7 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.errorhandling.saveLogsAsZip +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -65,9 +66,6 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -103,7 +101,7 @@ class DiagnosisActivity : BaseActivity() { val useEmbeddedDistrib = UnifiedPushUtils.hasEmbeddedDistributor(context) && !useUnifiedPush val showTestPushButton = isGooglePlayServicesAvailable || useUnifiedPush || useEmbeddedDistrib - setContent { + setContentWithStatusBanner { DiagnosisScreen( colorScheme = colorScheme, networkMonitor = networkMonitor, @@ -178,23 +176,23 @@ private fun DiagnosisScreen( stringResource(R.string.nc_settings_share_report_title) to onShareReportClick ) - MaterialTheme( - colorScheme = colorScheme - ) { - val isOnline = networkMonitor.isOnline.collectAsState().value - ColoredStatusBar() - Scaffold( - modifier = Modifier - .statusBarsPadding() - .displayCutoutPadding(), - topBar = { - StandardAppBar( - title = stringResource(R.string.nc_settings_diagnosis_title), - menuItems - ) - }, - content = { paddingValues -> - val viewState = diagnosisViewModel.notificationViewState.collectAsState().value + MaterialTheme( + colorScheme = colorScheme + ) { + val isOnline = networkMonitor.isOnline.collectAsState().value + ColoredStatusBar() + Scaffold( + modifier = Modifier + .statusBarsPadding() + .displayCutoutPadding(), + topBar = { + StandardAppBar( + title = stringResource(R.string.nc_settings_diagnosis_title), + menuItems + ) + }, + content = { paddingValues -> + val viewState = diagnosisViewModel.notificationViewState.collectAsState().value Column( Modifier 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/location/GeocodingActivity.kt b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt index b8a1e019f2d..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,7 +8,6 @@ package com.nextcloud.talk.location import android.content.Intent import android.os.Bundle -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector @@ -57,8 +56,9 @@ class GeocodingActivity : BaseActivity() { val email = context.getString(R.string.osm_geocoder_contact) TalkJsonNominatimClient(baseUrl, okHttpClient, email) - setContent { + setContentWithStatusBanner { val colorScheme = viewThemeUtils.getColorScheme(this) + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() GeocodingScreen( 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..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,7 +9,6 @@ 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.material3.MaterialTheme import androidx.lifecycle.ViewModelProvider @@ -98,7 +97,7 @@ class LocationPickerActivity : BaseActivity() { onBackPressedDispatcher.addCallback(this, onBackPressedCallback) val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() LocationPickerScreen( diff --git a/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt b/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt index 827a9a67e86..d342a9555a4 100644 --- a/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt @@ -63,6 +63,7 @@ import com.nextcloud.talk.components.StandardAppBar import com.nextcloud.talk.diagnosis.buildDiagnosisReportText import com.nextcloud.talk.diagnosis.showShareReportDialog import com.nextcloud.talk.errorhandling.saveLogsAsZip +import com.nextcloud.talk.components.StatusBannerRow import com.nextcloud.talk.logger.Level import com.nextcloud.talk.logger.LogEntry import com.nextcloud.talk.logger.LogsRepository @@ -104,7 +105,7 @@ class LogsActivity : BaseActivity() { val viewModel = ViewModelProvider(this, viewModelFactory)[LogsViewModel::class.java] val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { MaterialTheme(colorScheme = colorScheme) { val entries = viewModel.entries.collectAsState().value val isLoading = viewModel.isLoading.collectAsState().value 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..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,7 +8,6 @@ package com.nextcloud.talk.openconversations import android.content.Intent import android.os.Bundle -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.lifecycle.ViewModelProvider @@ -42,7 +41,7 @@ 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() 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..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,7 +10,6 @@ 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 @@ -127,36 +126,12 @@ class ProfileActivity : BaseActivity() { } val colorScheme = viewThemeUtils.getColorScheme(this) - setContent { + setContentWithStatusBanner { 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) - } - ) + callbacks = buildProfileCallbacks() ) scopeSheetRequest?.let { req -> ScopeModalBottomSheet( @@ -169,6 +144,33 @@ class ProfileActivity : BaseActivity() { } } + 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 d93fa5fd4b7..aaf96b39486 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -61,7 +61,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.setAppTheme import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.ConversationsListActivity.Companion.NOTIFICATION_WARNING_DATE_NOT_SET -import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySettingsBinding import com.nextcloud.talk.diagnosis.DiagnosisActivity @@ -127,9 +126,6 @@ class SettingsActivity : @Inject lateinit var ncApi: NcApi - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var ncApiCoroutines: NcApiCoroutines diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index eabb185de20..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 @@ -87,7 +86,7 @@ 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) MaterialTheme( 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..e3e7f0af9c6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/HttpStatusInterceptor.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +import android.util.Log +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) + 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 + 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 TAG = "HttpStatusInterceptor" + 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" + } +} diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml index aec5fc67534..df4eeba7117 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" /> + +