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 9e1b0aa4829..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,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 @@ -284,9 +285,6 @@ class ChatActivity : @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory @@ -617,6 +615,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) @@ -3999,6 +4002,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 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/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index 6f6f14708f0..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.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 7e9ee3ca0e6..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,6 +406,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 6bd495101f8..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,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 21e143ee0a6..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,10 +57,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 @@ -118,7 +119,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 @@ -139,9 +139,6 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var contactsViewModel: ContactsViewModel @@ -151,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) @@ -227,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) @@ -248,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, @@ -273,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() @@ -450,7 +447,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 +667,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() } } @@ -1424,14 +1417,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 +1515,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/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index 3338f341443..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 @@ -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,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( @@ -270,7 +272,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 aeef186e0b3..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.data.network.NetworkMonitor import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -57,9 +55,6 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var networkMonitor: NetworkMonitor - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -82,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( 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 3b8cb511a0b..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 @@ -77,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 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 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..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 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" /> + +