diff --git a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java index 75cb101dff..4503a65f6e 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java +++ b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java @@ -125,7 +125,7 @@ public void login() throws InterruptedException { activityScenario.onActivity(activity -> { assertEquals(loginName, - Objects.requireNonNull(activity.currentUserProviderOld.getCurrentUser().blockingGet()).getUserId()); + Objects.requireNonNull(activity.defaultAccountProvider.getDefaultUserBlocking()).getUserId()); }); } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba8c050440..3923ae833d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -233,10 +233,6 @@ android:name=".remotefilebrowser.activities.RemoteFileBrowserActivity" android:theme="@style/AppTheme" /> - - diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index f0ab828a56..b2cfa342f9 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -20,6 +20,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -78,9 +79,6 @@ class AccountVerificationActivity : BaseActivity() { @Inject lateinit var ncApiCoroutines: NcApiCoroutines - @Inject - lateinit var userManager: UserManager - @Inject lateinit var cookieManager: CookieManager @@ -440,6 +438,7 @@ class AccountVerificationActivity : BaseActivity() { ApplicationWideMessageHolder.MessageType.ACCOUNT_WAS_IMPORTED } val intent = Intent(context, ConversationsListActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userToSetAsActive.id) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) } else { diff --git a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt index 61ee119f85..e5c443eade 100644 --- a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt @@ -14,8 +14,10 @@ import android.content.Intent import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels import androidx.core.net.toUri import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector @@ -39,7 +41,9 @@ class BrowserLoginActivity : BaseActivity() { private lateinit var binding: ActivityWebViewLoginBinding @Inject - lateinit var viewModel: BrowserLoginActivityViewModel + lateinit var viewModelFactory: ViewModelProvider.Factory + + private val viewModel: BrowserLoginActivityViewModel by viewModels { viewModelFactory } private var reauthorizeAccount = false private val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -111,6 +115,13 @@ class BrowserLoginActivity : BaseActivity() { BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRestartApp -> { restartApp() } + BrowserLoginActivityViewModel.PostLoginViewState.PostLoginDifferentAccount -> { + Snackbar.make( + binding.root, + R.string.nc_reauthorize_different_account, + Snackbar.LENGTH_LONG + ).show() + } } } } @@ -124,17 +135,19 @@ class BrowserLoginActivity : BaseActivity() { if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) { reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT) } + // The account to reauthorize, so a login with another account doesn't change it. + val accountToReauthorize = extras.getLong(BundleKeys.KEY_INTERNAL_USER_ID, 0L).takeIf { it != 0L } if (extras.containsKey(BundleKeys.KEY_FROM_QR)) { val uri = extras.getString(BundleKeys.KEY_FROM_QR)!! if (uri.startsWith(LoginRepository.ONE_TIME_PREFIX)) { - viewModel.loginWithOTPQR(uri, reauthorizeAccount) + viewModel.loginWithOTPQR(uri, reauthorizeAccount, accountToReauthorize) } else { - viewModel.loginWithQR(uri, reauthorizeAccount) + viewModel.loginWithQR(uri, reauthorizeAccount, accountToReauthorize) } } else if (baseUrl != null) { - viewModel.startWebBrowserLogin(baseUrl, reauthorizeAccount) + viewModel.startWebBrowserLogin(baseUrl, reauthorizeAccount, accountToReauthorize) } } diff --git a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt index 6244e8482c..e07e823c15 100644 --- a/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/ServerSelectionActivity.kt @@ -37,7 +37,6 @@ import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.databinding.ActivityServerSelectionBinding import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall import com.nextcloud.talk.models.json.generic.StatusDto -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.AccountUtils import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -63,9 +62,6 @@ class ServerSelectionActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - @Inject lateinit var networkMonitor: NetworkMonitor diff --git a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt index 3198b3b179..b7242e848e 100644 --- a/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/SwitchAccountActivity.kt @@ -24,8 +24,8 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySwitchAccountBinding import com.nextcloud.talk.models.ImportAccount import com.nextcloud.talk.models.json.participants.ParticipantDto +import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.DirectShareHelper -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.AccountUtils.findAvailableAccountsOnDevice import com.nextcloud.talk.utils.AccountUtils.getInformationFromAccount import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL @@ -44,9 +44,6 @@ import javax.inject.Inject class SwitchAccountActivity : BaseActivity() { private lateinit var binding: ActivitySwitchAccountBinding - @Inject - lateinit var userManager: UserManager - @Inject lateinit var cookieManager: CookieManager @@ -86,6 +83,18 @@ class SwitchAccountActivity : BaseActivity() { supportActionBar?.title = resources!!.getString(R.string.nc_select_an_account) } + private fun switchToAccount(user: User) { + lifecycleScope.launch { + if (userManager.setUserAsActive(user)) { + DirectShareHelper.removeAllShareTargetShortcuts(this@SwitchAccountActivity) + cookieManager.cookieStore.removeAll() + val intent = ConversationsListActivity.createAccountSwitchIntent(this@SwitchAccountActivity, user.id!!) + startActivity(intent) + finish() + } + } + } + @Suppress("Detekt.NestedBlockDepth") override fun onResume() { super.onResume() @@ -95,13 +104,7 @@ class SwitchAccountActivity : BaseActivity() { if (isAccountImport) { reauthorizeFromImport(item.account) } else { - lifecycleScope.launch { - if (userManager.setUserAsActive(item.user!!)) { - DirectShareHelper.removeAllShareTargetShortcuts(this@SwitchAccountActivity) - cookieManager.cookieStore.removeAll() - finish() - } - } + switchToAccount(item.user!!) } } diff --git a/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt b/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt index 9699778cb1..c9a3bf05d6 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/LoginRepository.kt @@ -39,7 +39,22 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin private const val MAX_ARGS = 3 } + /** + * Result of [parseAndLogin]. + */ + sealed interface LoginResult { + /** A new account, to be verified and stored with the data in [bundle]. */ + data class NewAccount(val bundle: Bundle) : LoginResult + + /** An existing account, reauthorized or not, or an account that is being removed. */ + data object ExistingAccount : LoginResult + + /** A reauthorization in which a different account than the one to reauthorize logged in. */ + data object DifferentAccount : LoginResult + } + private var shouldReauthorizeUser = false + private var accountToReauthorize: Long? = null private var shouldLoop = true suspend fun pollLogin(response: LoginResponse): LoginCompletion? = @@ -63,8 +78,13 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin * Entry point for QR scanner * */ - fun startLoginFlowFromQR(dataString: String, reAuth: Boolean = false): LoginCompletion? { + fun startLoginFlowFromQR( + dataString: String, + reAuth: Boolean = false, + accountToReauthorize: Long? = null + ): LoginCompletion? { shouldReauthorizeUser = reAuth + this.accountToReauthorize = accountToReauthorize if (!dataString.startsWith(PREFIX)) { Log.e(TAG, "Invalid login URL detected") @@ -108,9 +128,14 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin /** * Entry point for a one-time QR code */ - suspend fun startOTPLoginFlow(dataString: String, reAuth: Boolean = false): LoginCompletion? = + suspend fun startOTPLoginFlow( + dataString: String, + reAuth: Boolean = false, + accountToReauthorize: Long? = null + ): LoginCompletion? = withContext(Dispatchers.IO) { shouldReauthorizeUser = reAuth + this@LoginRepository.accountToReauthorize = accountToReauthorize if (!dataString.startsWith(ONE_TIME_PREFIX)) { Log.e(TAG, "Invalid login URL detected") @@ -158,9 +183,14 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin /** * Entry point to the login process */ - suspend fun startLoginFlow(baseUrl: String, reAuth: Boolean = false): LoginResponse? = + suspend fun startLoginFlow( + baseUrl: String, + reAuth: Boolean = false, + accountToReauthorize: Long? = null + ): LoginResponse? = withContext(Dispatchers.IO) { shouldReauthorizeUser = reAuth + this@LoginRepository.accountToReauthorize = accountToReauthorize val response = network.anonymouslyPostLoginRequest(baseUrl) return@withContext response } @@ -173,23 +203,32 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin } /** - * Returns bundle if user is not scheduled for deletion or doesn't already exist, null otherwise + * Returns [LoginResult.NewAccount] if the account is not scheduled for deletion and doesn't exist yet. During a + * reauthorization, [LoginResult.DifferentAccount] if another account than the one to reauthorize logged in, set + * up or not, which is then left unchanged. */ - suspend fun parseAndLogin(loginData: LoginCompletion): Bundle? { + suspend fun parseAndLogin(loginData: LoginCompletion): LoginResult { if (local.checkIfUserIsScheduledForDeletion(loginData)) { // however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it. local.startAccountRemovalWorker() - return null + return LoginResult.ExistingAccount } else if (local.checkIfUserExists(loginData)) { if (shouldReauthorizeUser) { - local.updateUser(loginData) + if (!local.updateUser(loginData, accountToReauthorize)) { + Log.w(TAG, "Logged in with a different account than the one to reauthorize. Skipped update.") + return LoginResult.DifferentAccount + } } else { Log.w(TAG, "Tried to add an account that account already exists. Skipped user creation.") } - return null + return LoginResult.ExistingAccount + } else if (shouldReauthorizeUser) { + // A reauthorization must not add the account that logged in instead. + Log.w(TAG, "Logged in with an account that is not set up during a reauthorization. Skipped user creation.") + return LoginResult.DifferentAccount } else { - return startAccountVerification(loginData) + return LoginResult.NewAccount(startAccountVerification(loginData)) } } diff --git a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt index bef3bf3f39..395537520f 100644 --- a/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/account/data/io/LocalLoginDataSource.kt @@ -17,19 +17,26 @@ import com.nextcloud.talk.account.data.model.LoginCompletion import com.nextcloud.talk.jobs.AccountRemovalWorker import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.preferences.AppPreferences -import kotlinx.coroutines.runBlocking // local datasource for communicating with room through account manager // crucial for making sure the login process interacts with the db as expected. class LocalLoginDataSource(val userManager: UserManager, val appPreferences: AppPreferences, val context: Context) { - fun updateUser(loginData: LoginCompletion) { - val currentUser = runBlocking { userManager.getCurrentUser() } - if (currentUser != null) { - currentUser.clientCertificate = appPreferences.temporaryClientCertAlias - currentUser.token = loginData.appPassword - runBlocking { userManager.updateOrCreateUser(currentUser) } - } + /** + * Stores the new credentials of a reauthorized account. That is the account that logged in, found by its login + * name and server, as the browser login flow grants an app password for whoever is logged in there. Returns false + * without any change if it is not the account with the internal id [accountToReauthorize], or if nothing was + * stored, e.g. because the account was removed meanwhile. + */ + suspend fun updateUser(loginData: LoginCompletion, accountToReauthorize: Long?): Boolean { + val user = userManager.getUserWithUsernameAndServer(loginData.loginName, loginData.server) + ?.takeIf { accountToReauthorize == null || it.id == accountToReauthorize } + ?: return false + return userManager.updateCredentials( + user.id!!, + loginData.appPassword, + appPreferences.temporaryClientCertAlias + ) > 0 } fun startAccountRemovalWorker(): LiveData { diff --git a/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt b/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt index de3ae70b7c..481719fe07 100644 --- a/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModel.kt @@ -38,6 +38,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep data object PostLoginRestartApp : PostLoginViewState() data object PostLoginError : PostLoginViewState() data class PostLoginContinue(val data: Bundle) : PostLoginViewState() + data object PostLoginDifferentAccount : PostLoginViewState() } private val _postLoginState = MutableStateFlow(PostLoginViewState.None) @@ -52,9 +53,21 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep private var savedResponse: LoginResponse? = null - fun startWebBrowserLogin(baseUrl: String, reAuth: Boolean = false) { + // The view model survives a recreation of the activity, which starts the login again. A started login is + // continued instead of starting a second one, whose session the browser would not authorize. After process + // death the view model is new, so the login is started again. + private var isLoginStarted = false + + private fun startLoginOnce(): Boolean { + if (isLoginStarted) return false + isLoginStarted = true + return true + } + + fun startWebBrowserLogin(baseUrl: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { + if (!startLoginOnce()) return viewModelScope.launch { - val response = repository.startLoginFlow(baseUrl, reAuth) + val response = repository.startLoginFlow(baseUrl, reAuth, accountToReauthorize) savedResponse = response if (response == null) { @@ -77,52 +90,43 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep return@launch } - val bundle = repository.parseAndLogin(loginCompletionResponse) - if (bundle == null) { - _postLoginState.value = PostLoginViewState.PostLoginRestartApp - return@launch - } - - _postLoginState.value = PostLoginViewState.PostLoginContinue(bundle) + _postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse)) } } } - fun loginWithQR(dataString: String, reAuth: Boolean = false) { + fun loginWithQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { + if (!startLoginOnce()) return viewModelScope.launch { - val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth) + val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth, accountToReauthorize) if (loginCompletionResponse == null) { _postLoginState.value = PostLoginViewState.PostLoginError return@launch } - val bundle = repository.parseAndLogin(loginCompletionResponse) - if (bundle == null) { - _postLoginState.value = PostLoginViewState.PostLoginRestartApp - return@launch - } - - _postLoginState.value = PostLoginViewState.PostLoginContinue(bundle) + _postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse)) } } - fun loginWithOTPQR(dataString: String, reAuth: Boolean = false) { + fun loginWithOTPQR(dataString: String, reAuth: Boolean = false, accountToReauthorize: Long? = null) { + if (!startLoginOnce()) return viewModelScope.launch { - val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth) + val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth, accountToReauthorize) if (loginCompletionResponse == null) { _postLoginState.value = PostLoginViewState.PostLoginError return@launch } - val bundle = repository.parseAndLogin(loginCompletionResponse) - if (bundle == null) { - _postLoginState.value = PostLoginViewState.PostLoginRestartApp - return@launch - } - - _postLoginState.value = PostLoginViewState.PostLoginContinue(bundle) + _postLoginState.value = postLoginStateFor(repository.parseAndLogin(loginCompletionResponse)) } } + private fun postLoginStateFor(result: LoginRepository.LoginResult): PostLoginViewState = + when (result) { + is LoginRepository.LoginResult.NewAccount -> PostLoginViewState.PostLoginContinue(result.bundle) + LoginRepository.LoginResult.ExistingAccount -> PostLoginViewState.PostLoginRestartApp + LoginRepository.LoginResult.DifferentAccount -> PostLoginViewState.PostLoginDifferentAccount + } + fun cancelLogin() = repository.cancelLoginFlow() } diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index 366c7989b2..593d08b41a 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -31,6 +31,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.account.AccountVerificationActivity import com.nextcloud.talk.account.BrowserLoginActivity @@ -38,22 +39,25 @@ import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.account.SwitchAccountActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.CertificateEvent import com.nextcloud.talk.events.RemoteWipeEvent import com.nextcloud.talk.lock.LockedActivity +import com.nextcloud.talk.users.DefaultAccountProvider +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.ViewThemeUtilsFactory import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileViewerUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProvider -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.utils.ssl.TrustManager +import kotlinx.coroutines.runBlocking import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -80,22 +84,98 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var viewThemeUtilsFactory: ViewThemeUtilsFactory + @Inject lateinit var messageUtils: MessageUtils @Inject lateinit var context: Context - @Deprecated("Use CurrentUserProvider instead") @Inject - lateinit var currentUserProviderOld: CurrentUserProviderOld + lateinit var defaultAccountProvider: DefaultAccountProvider @Inject - lateinit var currentUserProvider: CurrentUserProvider + lateinit var userManager: UserManager @Inject lateinit var logger: Logger + /** + * Whether this activity may be started without [BundleKeys.KEY_INTERNAL_USER_ID] and then uses the default + * account. Only for entry points without an account context, like the conversation list on app start. For + * all other activities a missing id is a bug. + */ + protected open val allowsDefaultAccount: Boolean = false + + /** + * Internal id of the account this activity was started for, taken from [BundleKeys.KEY_INTERNAL_USER_ID]. + * Without it, the default account is used and its id is written to the intent, so an activity recreated after a + * configuration change stays on the same account. After process death the system restores the original intent, + * so the activity uses the default account at that time. That is only intended for activities with + * [allowsDefaultAccount]: for all others it fails in debug builds and is logged as an error in release builds. + * Returns 0 if there is no account at all. + */ + protected fun resolveUserIdFromIntent(): Long { + val userId = intent.getLongExtra(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + if (userId != 0L) { + return userId + } + if (!allowsDefaultAccount) { + val message = "${javaClass.simpleName} was started without ${BundleKeys.KEY_INTERNAL_USER_ID}" + check(!BuildConfig.DEBUG) { message } + Log.e(TAG, "$message, using the default account") + } + return (defaultAccountProvider.getDefaultUserBlocking()?.id ?: 0L).also { + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) + } + } + + /** + * The account this activity was started for (see [resolveUserIdFromIntent]), or null if it doesn't exist. For + * activities with [allowsDefaultAccount], a requested account that doesn't exist (anymore) falls back to the + * default account. Loaded once on first access, which must be after injection. + */ + private val boundUserLazy = lazy { + runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } + ?: if (allowsDefaultAccount) defaultAccountProvider.getDefaultUserBlocking() else null + } + private val boundUser: User? by boundUserLazy + + /** + * Returns [boundUser], or finishes the activity and returns null if the account doesn't exist. + */ + private fun requireBoundUserOrFinish(): User? = + boundUser ?: run { + Log.e(TAG, "No user found for id ${resolveUserIdFromIntent()}") + if (allowsDefaultAccount) { + startActivity(Intent(this, MainActivity::class.java)) + } + finish() + null + } + + private var userViewThemeUtils: ViewThemeUtils? = null + + /** + * Themes this activity, and the fragments and dialogs it hosts, with the server colors of [boundUser]. The theme + * is created once and assigned again on every call, because the injection of a subclass also injects + * [viewThemeUtils] and overwrites it with the theme of the default account. Reusing the instance keeps fragments + * that already took it over on the same one. + */ + private fun applyUserTheme() { + val themeUtils = userViewThemeUtils + ?: boundUser?.let { viewThemeUtilsFactory.forUser(it) }?.also { userViewThemeUtils = it } + themeUtils?.let { viewThemeUtils = it } + } + + /** + * Resolves [boundUser] and applies its theme, or finishes the activity and returns null if the account doesn't + * exist. Must be called right after injection, before anything else is set up. + */ + protected fun setUpBoundUserOrFinish(): User? = requireBoundUserOrFinish()?.also { applyUserTheme() } + open val appBarLayoutType: AppBarLayoutType get() = AppBarLayoutType.TOOLBAR @@ -130,6 +210,11 @@ open class BaseActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) logger.i(this::class.java.simpleName, "onCreate") + // Fragments restored in super.onCreate() take over viewThemeUtils there, so apply the theme of the account + // before. Without an account in the intent, the injected theme of the default account is the right one. + if (intent.hasExtra(BundleKeys.KEY_INTERNAL_USER_ID)) { + applyUserTheme() + } adjustUIForAPILevel35() super.onCreate(savedInstanceState) @@ -326,7 +411,10 @@ open class BaseActivity : AppCompatActivity() { } override fun startActivity(intent: Intent) { - val user = currentUserProviderOld.currentUser.blockingGet() + // Links to the own server are opened for the account of this screen. Screens without an account (boundUser + // never loaded) use the default account, without resolving one from their intent. + val user = (if (boundUserLazy.isInitialized()) boundUser else null) + ?: defaultAccountProvider.getDefaultUserBlocking() if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) { val uri = intent.data.toString() if (user?.baseUrl != null && uri.startsWith(user.baseUrl!!)) { @@ -344,10 +432,11 @@ open class BaseActivity : AppCompatActivity() { fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileIdNew(uri)) } else if (UriUtils.isInstanceInternalTalkUrl(user.baseUrl!!, uri)) { // https://cloud.nextcloud.com/call/123456789 - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, UriUtils.extractRoomTokenFromTalkUrl(uri)) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent( + context, + user.id!!, + UriUtils.extractRoomTokenFromTalkUrl(uri) + ) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } else { diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index cc3b96651c..490eb4a5db 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -59,6 +59,7 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.bluelinelabs.logansquare.LoganSquare import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -117,7 +118,6 @@ import com.nextcloud.talk.signaling.SignalingMessageReceiver.OfferMessageListene import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.ui.dialog.AudioOutputDialog import com.nextcloud.talk.ui.dialog.MoreCallActionsDialog -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability @@ -133,6 +133,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_WITHOUT_NOTIFICATION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_PASSWORD import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_BREAKOUT_ROOM import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MODIFIED_BASE_URL @@ -211,10 +212,6 @@ class CallActivity : CallBaseActivity() { @Inject var ncApi: NcApi? = null - @JvmField - @Inject - var userManager: UserManager? = null - @JvmField @Inject var cache: Cache? = null @@ -327,6 +324,9 @@ class CallActivity : CallBaseActivity() { private var hasExternalSignalingServer = false private var conversationPassword: String? = null private var powerManagerUtils: PowerManagerUtils? = null + + // Whether onCreate() got past its early exits, so onDestroy() has a call to clean up. + private var isCallSetUp = false private var handler: Handler? = null private val callingTimeoutRunnable = Runnable { setCallState(CallStatus.CALLING_TIMEOUT) } @@ -446,18 +446,7 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "onCreate") super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) - - // Register broadcast receiver for ending call from notification - val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) - - // internal receiver for notification actions, so not exported - registerPermissionHandlerBroadcastReceiver( - endCallFromNotificationReceiver, - endCallFilter, - permissionUtil!!.privateBroadcastPermission, - null, - ReceiverFlag.NotExported - ) + conversationUser = setUpBoundUserOrFinish() ?: return callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java] @@ -535,7 +524,6 @@ class CallActivity : CallBaseActivity() { return } processExtras(intent.extras!!) - conversationUser = currentUserProviderOld.currentUser.blockingGet() if (warnAndFinishIfCallEndToEndEncryptionUnsupported()) { return @@ -547,6 +535,25 @@ class CallActivity : CallBaseActivity() { } powerManagerUtils = PowerManagerUtils() + // Register broadcast receiver for ending call from notification + val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) + + // internal receiver for notification actions, so not exported + registerPermissionHandlerBroadcastReceiver( + endCallFromNotificationReceiver, + endCallFilter, + permissionUtil!!.privateBroadcastPermission, + null, + ReceiverFlag.NotExported + ) + + isCallSetUp = true + + if (!conversationUser.current) { + // Taking part in a call uses its account, so it becomes the last used one. + lifecycleScope.launch { userManager.setUserAsActive(conversationUser) } + } + setCallState(CallStatus.CONNECTING) initRaiseHandViewModel() @@ -576,7 +583,7 @@ class CallActivity : CallBaseActivity() { callRecordingViewModel = ViewModelProvider(this, viewModelFactory).get( CallRecordingViewModel::class.java ) - callRecordingViewModel!!.setData(roomToken!!) + callRecordingViewModel!!.setData(conversationUser, roomToken!!) callRecordingViewModel!!.setRecordingState(recordingState) callRecordingViewModel!!.viewState.observe(this) { viewState: CallRecordingViewModel.ViewState? -> if (viewState is RecordingStartedState) { @@ -634,7 +641,7 @@ class CallActivity : CallBaseActivity() { private fun initRaiseHandViewModel() { raiseHandViewModel = ViewModelProvider(this, viewModelFactory).get(RaiseHandViewModel::class.java) - raiseHandViewModel!!.setData(roomToken!!, isBreakoutRoom) + raiseHandViewModel!!.setData(conversationUser, roomToken!!, isBreakoutRoom) raiseHandViewModel!!.viewState.observe(this) { viewState: RaiseHandViewModel.ViewState? -> var raised = false if (viewState is RaisedHandState) { @@ -764,16 +771,18 @@ class CallActivity : CallBaseActivity() { return } val newRoomToken = intent.getStringExtra(KEY_ROOM_TOKEN) - Log.d(TAG, "onNewIntent: newRoomToken=$newRoomToken roomToken=$roomToken") + // Accounts on the same server share the tokens of their common conversations, so compare the account too. + val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, conversationUser.id!!) + Log.d(TAG, "onNewIntent: newRoomToken=$newRoomToken roomToken=$roomToken newUserId=$newUserId") when { // notification tap without extras: just bring the current call back to the front newRoomToken.isNullOrEmpty() -> Unit // re-entry for the call this instance is already handling (singleTask reuse) - newRoomToken == roomToken -> setIntent(intent) + newRoomToken == roomToken && newUserId == conversationUser.id -> setIntent(intent) - // a call for another room was requested while this instance lingered in the background: + // a call for another room or account was requested while this instance lingered in the background: // end the current call and restart cleanly in onDestroy, so no stale state is reused else -> { Log.d(TAG, "onNewIntent: call requested for another room, ending current call first") @@ -1557,36 +1566,42 @@ class CallActivity : CallBaseActivity() { public override fun onDestroy() { Log.d(TAG, "onDestroy: currentCallStatus=$currentCallStatus") - // The call cannot survive the activity being destroyed (WebRTC connections, local stream and - // signaling listeners all live here), so always clean up and hang up. Background survival is - // achieved via moveTaskToBack/PiP, which do not destroy the activity. - if (signalingMessageReceiver != null) { - signalingMessageReceiver!!.removeListener(localParticipantMessageListener) - signalingMessageReceiver!!.removeListener(offerMessageListener) - } - if (localStream != null) { - localStream!!.dispose() - localStream = null - Log.d(TAG, "Disposed localStream") - } else { - Log.d(TAG, "localStream is null") - } - if (currentCallStatus !== CallStatus.LEAVING) { - hangup(true, false) - } - CallForegroundService.stop(applicationContext) + if (isCallSetUp) { + // The call cannot survive the activity being destroyed (WebRTC connections, local stream and + // signaling listeners all live here), so always clean up and hang up. Background survival is + // achieved via moveTaskToBack/PiP, which do not destroy the activity. + if (signalingMessageReceiver != null) { + signalingMessageReceiver!!.removeListener(localParticipantMessageListener) + signalingMessageReceiver!!.removeListener(offerMessageListener) + } + if (localStream != null) { + localStream!!.dispose() + localStream = null + Log.d(TAG, "Disposed localStream") + } else { + Log.d(TAG, "localStream is null") + } + if (currentCallStatus !== CallStatus.LEAVING) { + hangup(true, false) + } + CallForegroundService.stop(applicationContext) - powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) - try { - unregisterReceiver(endCallFromNotificationReceiver) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) - } + try { + unregisterReceiver(endCallFromNotificationReceiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) + } - pendingCallIntent?.let { - Log.d(TAG, "onDestroy: starting CallActivity for pending call intent") - startActivity(it) + pendingCallIntent?.let { + Log.d(TAG, "onDestroy: starting CallActivity for pending call intent") + startActivity(it) + } + } else { + // The screen that started the call marked it as dialing, which hangup() would reset. Without resetting + // it here, chats would keep treating the app as being in a call. + ApplicationWideCurrentRoomHolder.getInstance().isDialing = false } super.onDestroy() @@ -1634,7 +1649,7 @@ class CallActivity : CallBaseActivity() { val userId = conversationUser!!.id!! val server = externalSignalingServer!! CoroutineScope(Dispatchers.IO).launch { - userManager!!.updateExternalSignalingServer(userId, server) + userManager.updateExternalSignalingServer(userId, server) } } else { conversationUser!!.externalSignalingServer = externalSignalingServer @@ -2324,14 +2339,12 @@ class CallActivity : CallBaseActivity() { openConversationListInPrimaryTask() finishAndRemoveTask() } else if (switchToRoomToken.isNotEmpty()) { - val intent = Intent(context, ChatActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) val bundle = Bundle() bundle.putBoolean(KEY_SWITCH_TO_ROOM, true) bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) - bundle.putString(KEY_ROOM_TOKEN, switchToRoomToken) bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) - intent.putExtras(bundle) + val intent = ChatActivity.createIntent(context, conversationUser.id!!, switchToRoomToken, bundle) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(intent) finish() } else if (shutDownView) { @@ -2367,6 +2380,7 @@ class CallActivity : CallBaseActivity() { if (primaryTask != null) { val intent = Intent(context, ConversationsListActivity::class.java).apply { + putExtra(KEY_INTERNAL_USER_ID, conversationUser.id) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) } primaryTask.startActivity(context, intent, null) diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 16606934f9..a6a1b0edc9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -33,7 +33,6 @@ import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityMainBinding import com.nextcloud.talk.invitation.InvitationsActivity -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.DeepLinkHandler @@ -41,7 +40,6 @@ import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers @@ -59,9 +57,6 @@ class MainActivity : @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - // MainActivity only routes to ConversationsListActivity or ChatActivity and is never // visible to the user. The lock check must run in the actual destination activity. override val skipLockCheckOnResume: Boolean = true @@ -167,7 +162,7 @@ class MainActivity : val baseUrl = userId.substringAfterLast("@") val isValidBaseUrl = baseUrl.isNotBlank() && "https://$baseUrl".toHttpUrlOrNull() != null - val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() if (isValidBaseUrl && currentUser?.baseUrl?.endsWith(baseUrl) == true) { startConversation(user) } else { @@ -188,13 +183,13 @@ class MainActivity : private fun startConversation(userId: String) { val roomType = "1" - val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() ?: return val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, 1)) - val credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token) + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( version = apiVersion, - baseUrl = currentUser?.baseUrl!!, + baseUrl = currentUser.baseUrl!!, roomType = roomType, invite = userId ) @@ -209,11 +204,11 @@ class MainActivity : .subscribe( { roomOverall -> if (isFinishing || isDestroyed) return@subscribe - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) - - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent( + context, + currentUser.id!!, + roomOverall.ocs!!.data!!.token + ) startActivity(chatIntent) }, { e -> @@ -251,6 +246,7 @@ class MainActivity : if (intent.hasExtra(BundleKeys.KEY_REMOTE_TALK_SHARE)) { if (intent.getBooleanExtra(BundleKeys.KEY_REMOTE_TALK_SHARE, false)) { val invitationsIntent = Intent(this@MainActivity, InvitationsActivity::class.java) + invitationsIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) startActivity(invitationsIntent) } } else { @@ -260,7 +256,7 @@ class MainActivity : } } else { try { - val users = userManager.getUsers() + val users = userManager.getUsersNotScheduledForDeletion() if (isFinishing || isDestroyed) return@launch if (users.isNotEmpty()) { @@ -339,9 +335,7 @@ class MainActivity : listIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) listIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtra(KEY_ROOM_TOKEN, deepLinkResult.roomToken) - chatIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, targetUser.id) + val chatIntent = ChatActivity.createIntent(context, targetUser.id!!, deepLinkResult.roomToken) startActivities(arrayOf(listIntent, chatIntent)) } else { @@ -377,7 +371,7 @@ class MainActivity : private fun resolveTargetUser(users: List, deepLinkResult: DeepLinkHandler.DeepLinkResult): User? { val deepLinkHost = deepLinkResult.serverUrl.toUri().host?.lowercase() if (deepLinkHost.isNullOrBlank()) { - return currentUserProviderOld.currentUser.blockingGet() + return defaultAccountProvider.getDefaultUserBlocking() } // Priority: exact match (username + server) > server match > current user fallback @@ -396,7 +390,7 @@ class MainActivity : userHost == deepLinkHost } - val currentUser = currentUserProviderOld.currentUser.blockingGet() + val currentUser = defaultAccountProvider.getDefaultUserBlocking() val currentUserMatch = currentUser?.takeIf { it.baseUrl?.let { url -> url.toUri().host?.lowercase() } == deepLinkHost } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt deleted file mode 100644 index f47aec87f8..0000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters.items - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.talk.R -import com.nextcloud.talk.databinding.RvItemLoadMoreBinding -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFilterable -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.viewholders.FlexibleViewHolder - -object LoadMoreResultsItem : - AbstractFlexibleItem(), - IFilterable { - - // layout is used as view type for uniqueness - const val VIEW_TYPE = FlexibleItemViewType.LOAD_MORE_RESULTS_ITEM - - class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - var binding: RvItemLoadMoreBinding = RvItemLoadMoreBinding.bind(view) - } - - override fun getLayoutRes(): Int = R.layout.rv_item_load_more - - override fun createViewHolder( - view: View, - adapter: FlexibleAdapter> - ): ViewHolder = ViewHolder(view, adapter) - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ViewHolder, - position: Int, - payloads: MutableList? - ) { - // nothing, it's immutable - } - - override fun filter(constraint: String?): Boolean = true - - override fun getItemViewType(): Int = VIEW_TYPE - - override fun equals(other: Any?): Boolean = other is LoadMoreResultsItem - - override fun hashCode(): Int = 0 -} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt deleted file mode 100644 index cbd9112421..0000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Tim Krüger - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017-2019 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters.items - -import android.content.Context -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.talk.R -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.RvItemSearchMessageBinding -import com.nextcloud.talk.extensions.loadThumbnail -import com.nextcloud.talk.models.domain.SearchMessageEntry -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFilterable -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.flexibleadapter.items.ISectionable -import eu.davidea.viewholders.FlexibleViewHolder - -data class MessageResultItem( - private val context: Context, - private val currentUser: User, - val messageEntry: SearchMessageEntry, - var showHeader: Boolean = false, - private val viewThemeUtils: ViewThemeUtils -) : AbstractFlexibleItem(), - IFilterable, - ISectionable { - - class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - var binding: RvItemSearchMessageBinding - - init { - binding = RvItemSearchMessageBinding.bind(view) - } - } - - override fun getLayoutRes(): Int = R.layout.rv_item_search_message - - override fun createViewHolder( - view: View, - adapter: FlexibleAdapter> - ): ViewHolder = ViewHolder(view, adapter) - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ViewHolder, - position: Int, - payloads: MutableList? - ) { - holder.binding.conversationTitle.text = messageEntry.title - bindMessageExcerpt(holder) - messageEntry.thumbnailURL?.let { holder.binding.thumbnail.loadThumbnail(it, currentUser) } - } - - private fun bindMessageExcerpt(holder: ViewHolder) { - viewThemeUtils.platform.highlightText( - holder.binding.messageExcerpt, - messageEntry.messageExcerpt, - messageEntry.searchTerm - ) - } - - override fun filter(constraint: String?): Boolean = true - - override fun getItemViewType(): Int = VIEW_TYPE - - companion object { - const val VIEW_TYPE = FlexibleItemViewType.MESSAGE_RESULT_ITEM - } - - override fun getHeader(): GenericTextHeaderItem = - MessagesTextHeaderItem(context, viewThemeUtils) - .apply { - isHidden = showHeader // FlexibleAdapter needs this hack for some reason - } - - override fun setHeader(header: GenericTextHeaderItem?) { - // nothing, header is always the same - } -} diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index b8bc0993fe..cec49766a6 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -39,6 +39,7 @@ import com.nextcloud.talk.diagnosis.toMarkdown import com.nextcloud.talk.errorhandling.ExceptionHandler import com.nextcloud.talk.logger.LoggerImpl import com.nextcloud.talk.account.BrowserLoginActivity +import com.nextcloud.talk.dagger.modules.ApplicationScope import com.nextcloud.talk.dagger.modules.BusModule import com.nextcloud.talk.dagger.modules.ContextModule import com.nextcloud.talk.dagger.modules.DaosModule @@ -65,6 +66,8 @@ import de.cotech.hw.SecurityKeyManager import de.cotech.hw.SecurityKeyManagerConfig import io.reactivex.exceptions.UndeliverableException import io.reactivex.plugins.RxJavaPlugins +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.conscrypt.Conscrypt import org.webrtc.PeerConnectionFactory @@ -112,6 +115,10 @@ class NextcloudTalkApplication : @Inject lateinit var userManager: UserManager + @Inject + @field:ApplicationScope + lateinit var appScope: CoroutineScope + @Inject lateinit var logger: LoggerImpl //endregion @@ -240,6 +247,8 @@ class NextcloudTalkApplication : } private fun initWorkers() { + appScope.launch { userManager.repairMultipleActiveUsers() } + val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build() val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesSyncWorker::class.java).build() val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build() diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt index dbcfaadb53..42677896a8 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -25,6 +25,7 @@ import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.preferences.AppPreferences import javax.inject.Inject @@ -101,6 +102,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel.setInitialFiles(filesList) diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 62936ab258..52f8171815 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -28,7 +28,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.CallNotificationActivityBinding import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.models.json.participants.ParticipantDto -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability @@ -38,7 +37,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import kotlinx.coroutines.runBlocking import okhttp3.Cache import java.io.IOException import javax.inject.Inject @@ -54,18 +52,13 @@ class CallNotificationActivity : CallBaseActivity() { @Inject var cache: Cache? = null - @Inject - lateinit var userManager: UserManager - private var roomToken: String? = null private var notificationTimestamp: Int? = null private var displayName: String? = null private var callFlag: Int = 0 private var isOneToOneCall: Boolean = true private var conversationName: String? = null - private var internalUserId: Long = -1 - - private var userBeingCalled: User? = null + private lateinit var userBeingCalled: User private var leavingScreen = false private var handler: Handler? = null private var binding: CallNotificationActivityBinding? = null @@ -73,12 +66,12 @@ class CallNotificationActivity : CallBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + userBeingCalled = setUpBoundUserOrFinish() ?: return binding = CallNotificationActivityBinding.inflate(layoutInflater) setContentView(binding!!.root) hideNavigationIfNoPipAvailable() handleExtras() - userBeingCalled = runBlocking { userManager.getUserWithId(internalUserId) } setupCallTypeDescription() setupCallAnswerButtons() @@ -96,13 +89,12 @@ class CallNotificationActivity : CallBaseActivity() { callFlag = extras.getInt(BundleKeys.KEY_CALL_FLAG) isOneToOneCall = extras.getBoolean(KEY_ROOM_ONE_TO_ONE) conversationName = extras.getString(BundleKeys.KEY_CONVERSATION_NAME, "") - internalUserId = extras.getLong(BundleKeys.KEY_INTERNAL_USER_ID) } private fun setupAvatar(isOneToOneCall: Boolean, conversationName: String?) { if (isOneToOneCall) { binding!!.avatarImageView.loadUserAvatar( - userBeingCalled!!, + userBeingCalled, conversationName!!, true, false @@ -114,7 +106,7 @@ class CallNotificationActivity : CallBaseActivity() { private fun hasCallFlagsCapability(): Boolean { val apiVersion = ApiUtils.getConversationApiVersion( - userBeingCalled!!, + userBeingCalled, intArrayOf( ApiUtils.API_V4, ApiUtils.API_V3, @@ -124,7 +116,7 @@ class CallNotificationActivity : CallBaseActivity() { return apiVersion >= ApiUtils.API_V3 && hasSpreedFeatureCapability( - userBeingCalled?.capabilities?.spreedCapability, + userBeingCalled.capabilities?.spreedCapability, SpreedFeatures.CONVERSATION_CALL_FLAGS ) } @@ -206,7 +198,7 @@ class CallNotificationActivity : CallBaseActivity() { } private fun proceedToCall() { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled?.capabilities?.spreedCapability)) { + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled.capabilities?.spreedCapability)) { Toast.makeText(context, R.string.nc_call_e2ee_not_supported, Toast.LENGTH_LONG).show() hangup() return diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 164916cc03..54749edcb4 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -92,6 +92,7 @@ import androidx.media3.session.SessionToken import androidx.recyclerview.widget.LinearLayoutManager import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -130,7 +131,6 @@ import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User @@ -293,13 +293,18 @@ class ChatActivity : @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory - val chatViewModel: ChatViewModel by viewModels { - ViewModelFactoryWithParams(ChatViewModel::class.java) { - chatViewModelFactory.build( - roomToken, - conversationThreadId - ) - } + /** + * The user of this chat as loaded in [onCreate], used to create [chatViewModel]. Afterwards use + * [conversationUser], which reflects later changes of the stored user. + */ + private lateinit var initialUser: User + + val chatViewModel: ChatViewModel by assistedViewModels { + chatViewModelFactory.build( + initialUser, + roomToken, + conversationThreadId + ) } lateinit var conversationInfoViewModel: ConversationInfoViewModel @@ -362,6 +367,8 @@ class ChatActivity : ?: error("roomToken missing") } + val conversationUserId: Long by lazy { resolveUserIdFromIntent() } + val conversationThreadId: Long? by lazy { if (intent.hasExtra(KEY_THREAD_ID)) { intent.getLongExtra(KEY_THREAD_ID, 0L) @@ -372,7 +379,8 @@ class ChatActivity : var openedViaNotification: Boolean = false var conversationThreadInfo: ThreadInfoDto? = null - lateinit var conversationUser: User + val conversationUser: User + get() = chatViewModel.currentUser lateinit var spreedCapabilities: SpreedCapabilityDto var chatApiVersion: Int = 1 private var roomPassword: String = "" @@ -504,6 +512,7 @@ class ChatActivity : onBackPressedDispatcher.onBackPressed() } else { val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) finish() @@ -591,6 +600,9 @@ class ChatActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), + // and registerForActivityResult() must be called before the activity is started. + initialUser = setUpBoundUserOrFinish() ?: return binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) @@ -635,61 +647,47 @@ class ChatActivity : overflowContainerHeightPx.intValue = binding.chatOverflowContainer.height } - lifecycleScope.launch { - currentUserProvider.getCurrentUser() - .onSuccess { user -> - conversationUser = user - handleIntent(intent) - val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) - val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) - - chatViewModel.initData( - user, - credentials!!, - urlForChatting, - conversationThreadId - ) + handleIntent(intent) + chatViewModel.initData() + if (sharedText.isNotEmpty()) { + chatViewModel.messageDraft.messageText = sharedText + chatViewModel.saveMessageDraft() + } - conversationThreadId?.let { - val threadUrl = ApiUtils.getUrlForThread( - version = 1, - baseUrl = conversationUser!!.baseUrl, - token = roomToken, - threadId = it.toInt() - ) - chatViewModel.getThread(credentials, threadUrl) - } + conversationThreadId?.let { + val threadUrl = ApiUtils.getUrlForThread( + version = 1, + baseUrl = conversationUser.baseUrl, + token = roomToken, + threadId = it.toInt() + ) + chatViewModel.getThread(conversationUser.getCredentials(), threadUrl) + } - messageInputFragment = getMessageInputFragment() - messageInputViewModel.setData(chatViewModel.getChatRepository()) + messageInputFragment = getMessageInputFragment() + messageInputViewModel.setData(chatViewModel.getChatRepository()) - initObservers() + initObservers() - pendingTargetMessageId?.let { messageId -> - lifecycleScope.launch { - chatViewModel.openMessageFromGlobalSearch( - messageId = messageId, - threadId = pendingTargetThreadId, - searchQuery = pendingTargetSearchQuery - ) - } - pendingTargetMessageId = null - pendingTargetThreadId = null - pendingTargetSearchQuery = null - } + pendingTargetMessageId?.let { messageId -> + lifecycleScope.launch { + chatViewModel.openMessageFromGlobalSearch( + messageId = messageId, + threadId = pendingTargetThreadId, + searchQuery = pendingTargetSearchQuery + ) + } + pendingTargetMessageId = null + pendingTargetThreadId = null + pendingTargetSearchQuery = null + } - pickMultipleMedia = registerForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER) - ) { uris -> - if (uris.isNotEmpty()) { - onChooseFileResult(uris) - } - } - } - .onFailure { - logger.e(TAG, "Failed to register media picker activity result launcher", it) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } + pickMultipleMedia = registerForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER) + ) { uris -> + if (uris.isNotEmpty()) { + onChooseFileResult(uris) + } } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) @@ -982,19 +980,15 @@ class ChatActivity : value = reactionsSheetMessageId?.let { id -> chatViewModel.getMessageById(id).first() } } reactionsSheetMessage?.let { msg -> - if (::conversationUser.isInitialized) { - conversationUser.let { user -> - ShowReactionsModalBottomSheet( - chatMessage = msg, - user = user, - roomToken = roomToken, - hasReactPermission = participantPermissions?.hasReactPermission() == true, - ncApiCoroutines = ncApiCoroutines, - onDeleteReaction = { emoji -> chatViewModel.deleteReaction(roomToken, msg, emoji) }, - onDismiss = { chatViewModel.dismissReactionsSheet() } - ) - } - } + ShowReactionsModalBottomSheet( + chatMessage = msg, + user = conversationUser, + roomToken = roomToken, + hasReactPermission = participantPermissions?.hasReactPermission() == true, + ncApiCoroutines = ncApiCoroutines, + onDeleteReaction = { emoji -> chatViewModel.deleteReaction(roomToken, msg, emoji) }, + onDismiss = { chatViewModel.dismissReactionsSheet() } + ) } val profileSheetMessageId by chatViewModel.profileSheetMessageId.collectAsStateWithLifecycle() @@ -1005,17 +999,13 @@ class ChatActivity : ?.takeIf { it.actorType.equals("users") } ?.let { msg -> val actorId = msg.actorId ?: return@let - if (::conversationUser.isInitialized) { - conversationUser.let { user -> - ProfileModalBottomSheet( - actorId = actorId, - user = user, - ncApiCoroutines = ncApiCoroutines, - onTalkTo = { actorId -> startDirectChat(actorId) }, - onDismiss = { chatViewModel.dismissProfileSheet() } - ) - } - } + ProfileModalBottomSheet( + actorId = actorId, + user = conversationUser, + ncApiCoroutines = ncApiCoroutines, + onTalkTo = { actorId -> startDirectChat(actorId) }, + onDismiss = { chatViewModel.dismissProfileSheet() } + ) } val messageActionsMessageId by chatViewModel.messageActionsMessageId.collectAsStateWithLifecycle() @@ -1039,62 +1029,58 @@ class ChatActivity : onDismiss = { chatViewModel.dismissMessageActions() } ) } else { - if (::conversationUser.isInitialized) { - conversationUser.let { user -> - MessageActionsBottomSheet( - actionsState = buildMessageActionsState( - message = msg, - user = user, - conversation = currentConversation, - hasChatPermission = participantPermissions?.hasChatPermission() == true, - hasReactPermission = participantPermissions?.hasReactPermission() == true, - spreedCapabilities = spreedCapabilities, - isOnline = isOnline, - dateUtils = dateUtils, - conversationThreadId = conversationThreadId - ), - onEmojiClick = { emoji -> - if (msg.reactionsSelf?.contains(emoji) == true) { - chatViewModel.deleteReaction(roomToken, msg, emoji) - } else { - chatViewModel.addReaction(roomToken, msg, emoji) - } - }, - onReply = { - if (msg.isThread && conversationThreadId == null) { - openThread(msg) - } else { - messageInputViewModel.reply(msg) - } - }, - onReplyPrivately = { replyPrivately(msg) }, - onOpenThread = { msg.threadId?.let { openThread(it) } }, - onForward = { forwardMessage(msg) }, - onEdit = { messageInputViewModel.edit(msg) }, - onCopy = { copyMessage(msg) }, - onCopyMessageLink = { copyMessageLink(msg) }, - onMarkAsUnread = { markAsUnread(msg) }, - onRemind = { remindMeLater(msg) }, - onPin = { pinMessage(msg) }, - onUnpin = { unPinMessage(msg) }, - onTranslate = { translateMessage(msg) }, - onShareToNote = { shareToNotes(msg) }, - onShare = { - if (msg.getCalculateMessageType() == - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE - ) { - checkIfSharable(msg) - } else { - msg.message?.let { shareMessageText(it) } - } - }, - onSave = { checkIfSaveable(msg) }, - onOpenInFiles = { openInFilesApp(msg) }, - onDelete = { deleteMessage(msg) }, - onDismiss = { chatViewModel.dismissMessageActions() } - ) - } - } + MessageActionsBottomSheet( + actionsState = buildMessageActionsState( + message = msg, + user = conversationUser, + conversation = currentConversation, + hasChatPermission = participantPermissions?.hasChatPermission() == true, + hasReactPermission = participantPermissions?.hasReactPermission() == true, + spreedCapabilities = spreedCapabilities, + isOnline = isOnline, + dateUtils = dateUtils, + conversationThreadId = conversationThreadId + ), + onEmojiClick = { emoji -> + if (msg.reactionsSelf?.contains(emoji) == true) { + chatViewModel.deleteReaction(roomToken, msg, emoji) + } else { + chatViewModel.addReaction(roomToken, msg, emoji) + } + }, + onReply = { + if (msg.isThread && conversationThreadId == null) { + openThread(msg) + } else { + messageInputViewModel.reply(msg) + } + }, + onReplyPrivately = { replyPrivately(msg) }, + onOpenThread = { msg.threadId?.let { openThread(it) } }, + onForward = { forwardMessage(msg) }, + onEdit = { messageInputViewModel.edit(msg) }, + onCopy = { copyMessage(msg) }, + onCopyMessageLink = { copyMessageLink(msg) }, + onMarkAsUnread = { markAsUnread(msg) }, + onRemind = { remindMeLater(msg) }, + onPin = { pinMessage(msg) }, + onUnpin = { unPinMessage(msg) }, + onTranslate = { translateMessage(msg) }, + onShareToNote = { shareToNotes(msg) }, + onShare = { + if (msg.getCalculateMessageType() == + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE + ) { + checkIfSharable(msg) + } else { + msg.message?.let { shareMessageText(it) } + } + }, + onSave = { checkIfSaveable(msg) }, + onOpenInFiles = { openInFilesApp(msg) }, + onDelete = { deleteMessage(msg) }, + onDismiss = { chatViewModel.dismissMessageActions() } + ) } } } @@ -1272,7 +1258,6 @@ class ChatActivity : private fun startDirectChat(actorId: String) { lifecycleScope.launch { try { - if (!::conversationUser.isInitialized) return@launch val user = conversationUser val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( @@ -1286,10 +1271,7 @@ class ChatActivity : retrofitBucket.url, retrofitBucket.queryMap ) - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) - val chatIntent = Intent(this@ChatActivity, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(this@ChatActivity, user.id!!, roomOverall.ocs!!.data!!.token) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } catch (e: Exception) { @@ -1332,7 +1314,13 @@ class ChatActivity : if (isViewableMedia && seedItems.any { it.messageId == messageId.toLong() }) { startActivity( - MediaViewerActivity.newIntent(this@ChatActivity, roomToken, seedItems, messageId.toLong()) + MediaViewerActivity.newIntent( + this@ChatActivity, + conversationUserId, + roomToken, + seedItems, + messageId.toLong() + ) ) } else { fileViewerUtils.openFile(chatMessage, openWhenDownloadState, downloadState) @@ -1370,6 +1358,14 @@ class ChatActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, conversationUserId) + if (newUserId != conversationUserId) { + // A chat instance is bound to one account, so open a fresh instance for the other one. + intent.removeFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + finish() + startActivity(intent) + return + } val extras: Bundle? = intent.extras val requestedRoomSwitch = extras?.getBoolean(KEY_SWITCH_TO_ROOM, false) == true @@ -1630,25 +1626,23 @@ class ChatActivity : if (state.conversationModel.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && state.conversationModel.status == "dnd" ) { - conversationUser.let { user -> - val credentials = ApiUtils.getCredentials(user.username, user.token) + conversationUser.let { + val credentials = ApiUtils.getCredentials(it.username, it.token) chatViewModel.outOfOfficeStatusOfUser( credentials!!, - user.baseUrl!!, + it.baseUrl!!, state.conversationModel!!.name ) } } - if (::conversationUser.isInitialized) { - conversationUser.let { user -> - val credentials = ApiUtils.getCredentials(user.username, user.token) - chatViewModel.fetchUpcomingEvent( - credentials!!, - user.baseUrl!!, - roomToken - ) - } + conversationUser.let { + val credentials = ApiUtils.getCredentials(it.username, it.token) + chatViewModel.fetchUpcomingEvent( + credentials!!, + it.baseUrl!!, + roomToken + ) } if (state.conversationModel.objectType == ConversationEnums.ObjectType.EVENT && @@ -1820,12 +1814,10 @@ class ChatActivity : chatViewModel.createRoomViewState.observe(this) { state -> when (state) { is ChatViewModel.CreateRoomSuccessState -> { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token) + val token = state.roomOverall.ocs!!.data!!.token leaveRoom { - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, token) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } @@ -2159,7 +2151,7 @@ class ChatActivity : fun updateToolbarState() { val conversation = currentConversation - val user = if (::conversationUser.isInitialized) conversationUser else null + val user = conversationUser val isOneToOne = isOneToOneConversation() val capabilitiesReady = ::spreedCapabilities.isInitialized @@ -2171,11 +2163,11 @@ class ChatActivity : subtitle = buildToolbarSubtitle(conversation), avatarType = buildAvatarType(conversation), avatarUrl = buildAvatarUrl(user, conversation), - credentials = user?.let { ApiUtils.getCredentials(it.username, it.token) }, + credentials = ApiUtils.getCredentials(user.username, user.token), userStatus = if (isOneToOne) conversation?.status else null, showVoiceCall = isCallsEnabled(capabilitiesReady, conversation), showVideoCall = isCallsEnabled(capabilitiesReady, conversation), - titleClickable = user?.userId != "?" && !chatToolbarState.isSearchMode, + titleClickable = user.userId != "?" && !chatToolbarState.isSearchMode, overflowItems = buildOverflowItems(), threadNotificationIcon = buildThreadNotificationIcon(capabilitiesReady), showEventMenu = conversation?.objectType == ConversationEnums.ObjectType.EVENT, @@ -2346,35 +2338,31 @@ class ChatActivity : } private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) { - if (::conversationUser.isInitialized) { - runOnUiThread { - val toastInfo = if (currentConversation?.objectType == ConversationEnums.ObjectType.ROOM) { - context.resources.getString(R.string.switch_to_main_room) - } else { - context.resources.getString(R.string.switch_to_breakout_room) - } - // do not replace with snackbar, as it would disappear with the activity switch - Toast.makeText( - context, - toastInfo, - Toast.LENGTH_LONG - ).show() + runOnUiThread { + val toastInfo = if (currentConversation?.objectType == ConversationEnums.ObjectType.ROOM) { + context.resources.getString(R.string.switch_to_main_room) + } else { + context.resources.getString(R.string.switch_to_breakout_room) } + // do not replace with snackbar, as it would disappear with the activity switch + Toast.makeText( + context, + toastInfo, + Toast.LENGTH_LONG + ).show() + } - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, token) + val bundle = Bundle() - if (startCallAfterRoomSwitch) { - bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) - bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) - } + if (startCallAfterRoomSwitch) { + bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) + bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) + } - leaveRoom { - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) - } + leaveRoom { + val chatIntent = createIntent(context, conversationUserId, token, bundle) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) } } @@ -2405,8 +2393,6 @@ class ChatActivity : chatViewModel.syncVoiceMessageUiState(message) message.openWhenDownloaded = openWhenDownloaded - val baseUrl = conversationUser.baseUrl - val userId = conversationUser.userId val attachmentFolder = CapabilitiesUtil.getAttachmentFolder( conversationUser.capabilities!! .spreedCapability!! @@ -2435,8 +2421,7 @@ class ChatActivity : } val data: Data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId) + .putLong(KEY_INTERNAL_USER_ID, conversationUserId) .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) @@ -2897,6 +2882,7 @@ class ChatActivity : fun showBrowserScreen() { val sharingFileBrowserIntent = Intent(this, RemoteFileBrowserActivity::class.java) + sharingFileBrowserIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startRemoteFileBrowsingForResult.launch(sharingFileBrowserIntent) } @@ -2914,6 +2900,7 @@ class ChatActivity : if (permissionUtil.isLocationPermissionGranted() && isGpsEnabled) { val intent = Intent(this, LocationPickerActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion) startActivity(intent) @@ -2980,6 +2967,7 @@ class ChatActivity : val intent = Intent(this, ConversationInfoActivity::class.java) intent.putExtras(bundle) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startActivity(intent) } @@ -2990,17 +2978,15 @@ class ChatActivity : @Suppress("Detekt.TooGenericExceptionCaught") private fun cancelNotificationsForCurrentConversation() { - if (::conversationUser.isInitialized) { - if (!TextUtils.isEmpty(roomToken)) { - try { - NotificationUtils.cancelExistingNotificationsForRoom( - applicationContext, - conversationUser!!, - roomToken - ) - } catch (e: RuntimeException) { - Log.w(TAG, "Cancel notifications for current conversation results with an error.", e) - } + if (!TextUtils.isEmpty(roomToken)) { + try { + NotificationUtils.cancelExistingNotificationsForRoom( + applicationContext, + conversationUser!!, + roomToken + ) + } catch (e: RuntimeException) { + Log.w(TAG, "Cancel notifications for current conversation results with an error.", e) } } } @@ -3021,7 +3007,7 @@ class ChatActivity : getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) } - if (::conversationUser.isInitialized && isActivityNotChangingConfigurations() && isNotInCall()) { + if (isActivityNotChangingConfigurations() && isNotInCall()) { if (isLeavingRoom) { Log.d(TAG, "not leaving room (leave already in progress)") } else if (validSessionId()) { @@ -3070,7 +3056,9 @@ class ChatActivity : super.onDestroy() logConversationInfos("onDestroy") - chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) + if (::initialUser.isInitialized) { + chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) + } findViewById(R.id.toolbar)?.setOnClickListener(null) @@ -3083,8 +3071,10 @@ class ChatActivity : private fun joinRoomWithPassword() { // if ApplicationWideCurrentRoomHolder contains a session (because a call is active), then keep the sessionId - if (ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken == - currentConversation!!.token + if (ApplicationWideCurrentRoomHolder.getInstance().isCurrentRoom( + currentConversation!!.token, + conversationUser.id + ) ) { sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session @@ -3117,11 +3107,8 @@ class ChatActivity : } sessionIdAfterRoomJoined = "0" - var apiVersion = 1 // FIXME Fix API checking with guests? - if (::conversationUser.isInitialized) { - apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) - } + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) val startNanoTime = System.nanoTime() Log.d(TAG, "leaveRoom - leaveRoom - calling: $startNanoTime") @@ -3137,8 +3124,8 @@ class ChatActivity : } private fun setupWebsocket() { - if (currentConversation == null || !::conversationUser.isInitialized) { - Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") + if (currentConversation == null) { + Log.e(TAG, "setupWebsocket: currentConversation is null") return } @@ -3231,6 +3218,7 @@ class ChatActivity : private fun openScheduledMessages() { val intent = Intent(this, ScheduledMessagesActivity::class.java).apply { putExtra(ScheduledMessagesActivity.ROOM_TOKEN, roomToken) + putExtra(KEY_INTERNAL_USER_ID, conversationUserId) putExtra(ScheduledMessagesActivity.CONVERSATION_NAME, currentConversation?.displayName.orEmpty()) if (conversationThreadId != null && conversationThreadId!! > 0) { putExtra(ScheduledMessagesActivity.THREAD_ID, conversationThreadId) @@ -3471,6 +3459,7 @@ class ChatActivity : private fun showSharedItems() { val intent = Intent(this, SharedItemsActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra( @@ -3505,20 +3494,18 @@ class ChatActivity : private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { currentConversation?.let { - if (::conversationUser.isInitialized) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(spreedCapabilities)) { - Snackbar.make(binding.root, R.string.nc_call_e2ee_not_supported, Snackbar.LENGTH_LONG).show() - return - } - val pp = ParticipantPermissions(spreedCapabilities, it) - if (!pp.canStartCall() && currentConversation?.hasCall == false) { - Snackbar.make(binding.root, R.string.startCallForbidden, Snackbar.LENGTH_LONG).show() - } else { - ApplicationWideCurrentRoomHolder.getInstance().isDialing = true - val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) - if (callIntent != null) { - startActivity(callIntent) - } + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(spreedCapabilities)) { + Snackbar.make(binding.root, R.string.nc_call_e2ee_not_supported, Snackbar.LENGTH_LONG).show() + return + } + val pp = ParticipantPermissions(spreedCapabilities, it) + if (!pp.canStartCall() && currentConversation?.hasCall == false) { + Snackbar.make(binding.root, R.string.startCallForbidden, Snackbar.LENGTH_LONG).show() + } else { + ApplicationWideCurrentRoomHolder.getInstance().isDialing = true + val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) + if (callIntent != null) { + startActivity(callIntent) } } } @@ -3556,6 +3543,7 @@ class ChatActivity : val callIntent = Intent(this, CallActivity::class.java) callIntent.putExtras(bundle) + callIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) return callIntent } ?: run { return null @@ -3634,11 +3622,8 @@ class ChatActivity : ) Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } else { - var apiVersion = 1 // FIXME Fix API checking with guests? - if (::conversationUser.isInitialized) { - apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) - } + val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) chatViewModel.deleteChatMessages( credentials!!, @@ -3677,6 +3662,7 @@ class ChatActivity : val intent = Intent(this, ConversationsListActivity::class.java) intent.putExtras(bundle) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) finish() @@ -3697,7 +3683,8 @@ class ChatActivity : setContent { DateTimeCompose( bundle, - chatViewModel + chatViewModel, + conversationUser ).GetDateTimeDialog(shouldDismiss, this@ChatActivity) } } @@ -3802,6 +3789,7 @@ class ChatActivity : bundle.putString(BundleKeys.KEY_TRANSLATE_MESSAGE, message?.getRichText()) val intent = Intent(this, TranslateActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) intent.putExtras(bundle) startActivity(intent) } @@ -4022,10 +4010,7 @@ class ChatActivity : } fun openNoteToSelfConversation(noteToSelfRoomToken: String) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, noteToSelfRoomToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, noteToSelfRoomToken) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } @@ -4084,8 +4069,6 @@ class ChatActivity : } fun userAllowedByPrivilages(message: ChatMessage): Boolean { - if (!::conversationUser.isInitialized) return false - val isUserAllowedByPrivileges = if (message.actorId == conversationUser!!.userId) { true } else { @@ -4182,9 +4165,7 @@ class ChatActivity : } fun createPoll() { - val pollVoteDialog = PollCreateDialogFragment.newInstance( - roomToken - ) + val pollVoteDialog = PollCreateDialogFragment.newInstance(conversationUser, roomToken) pollVoteDialog.show(supportFragmentManager, TAG) } @@ -4196,10 +4177,8 @@ class ChatActivity : fun openThread(messageId: Long) { val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomToken) bundle.putLong(KEY_THREAD_ID, messageId) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = createIntent(context, conversationUserId, roomToken, bundle) startActivity(chatIntent) } @@ -4217,6 +4196,7 @@ class ChatActivity : val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java) threadsOverviewIntent.putExtras(bundle) + threadsOverviewIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startActivity(threadsOverviewIntent) } @@ -4303,6 +4283,16 @@ class ChatActivity : companion object { val TAG = ChatActivity::class.java.simpleName + + /** + * Creates an intent that opens the conversation [roomToken] of the account with the internal id [userId]. + */ + fun createIntent(context: Context, userId: Long, roomToken: String, extras: Bundle? = null): Intent = + Intent(context, ChatActivity::class.java).apply { + extras?.let { putExtras(it) } + putExtra(KEY_ROOM_TOKEN, roomToken) + putExtra(KEY_INTERNAL_USER_ID, userId) + } private const val CONTENT_TYPE_CALL_STARTED: Byte = 1 private const val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 2 private const val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 3 diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 6b6472b576..a5d3fa86c7 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -76,6 +76,7 @@ import com.nextcloud.talk.ui.CallStartedBanner import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -137,14 +138,12 @@ class MessageInputFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) conversationInternalId = arguments?.getString(ChatActivity.CONVERSATION_INTERNAL_ID).orEmpty() chatActivity = requireActivity() as ChatActivity - val sharedText = arguments?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() - if (sharedText.isNotEmpty()) { - hasSharedText = true - chatActivity.chatViewModel.messageDraft.messageText = sharedText - chatActivity.chatViewModel.saveMessageDraft() - } + // The shared text itself is put into the draft by ChatActivity. This fragment may be restored before + // ChatActivity has created its view model, so it must not access it here. + hasSharedText = arguments?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty().isNotEmpty() if (conversationInternalId.isEmpty()) { Log.e(TAG, "internalId for conversation passed to MessageInputFragment is empty") } @@ -780,6 +779,7 @@ class MessageInputFragment : Fragment() { val backgroundDrawable = it.getColor(R.color.bg_default, null).toDrawable() val presenter = MentionAutocompletePresenter( requireContext(), + chatActivity.conversationUser, chatActivity.roomToken, chatActivity.chatApiVersion ) diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt index a6e1e2f957..6832cfcf70 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -27,6 +27,7 @@ import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -54,6 +55,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt index b46932a1df..b1c2a71bdb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ScheduledMessagesActivity.kt @@ -9,7 +9,6 @@ package com.nextcloud.talk.chat import android.content.ClipData import android.content.ClipboardManager -import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.annotation.DrawableRes @@ -82,7 +81,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.widget.addTextChangedListener import androidx.emoji2.widget.EmojiEditText -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.R @@ -93,6 +91,7 @@ import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.ui.model.toScheduledMessageUiModel import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero @@ -110,7 +109,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -127,12 +125,16 @@ private const val STICKY_HEADER_SCROLL_DELAY = 1200L class ScheduledMessagesActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ScheduledMessagesViewModel.Factory @Inject lateinit var dateUtils: DateUtils - private lateinit var scheduledMessagesViewModel: ScheduledMessagesViewModel + private val scheduledMessagesViewModel: ScheduledMessagesViewModel by assistedViewModels { + viewModelFactory.build(conversationUser) + } + + private lateinit var conversationUser: User @Inject lateinit var networkMonitor: NetworkMonitor @@ -164,14 +166,10 @@ class ScheduledMessagesActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - scheduledMessagesViewModel = ViewModelProvider(this, viewModelFactory)[ScheduledMessagesViewModel::class.java] + conversationUser = setUpBoundUserOrFinish() ?: return setContent { val colorScheme = viewThemeUtils.getColorScheme(this) - val currentUser by scheduledMessagesViewModel.currentUserState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - scheduledMessagesViewModel.loadCurrentUser() - } MaterialTheme(colorScheme = colorScheme) { CompositionLocalProvider( LocalViewThemeUtils provides viewThemeUtils, @@ -179,46 +177,43 @@ class ScheduledMessagesActivity : BaseActivity() { LocalShowThreadButton provides false ) { ColoredStatusBar() - currentUser?.let { user -> - ScheduledMessagesScreen( - user = user, - conversationName = conversationName, - scheduledMessagesViewModel = scheduledMessagesViewModel, - dateUtils = dateUtils, - viewThemeUtils = viewThemeUtils, - onBack = { finish() }, - onLoadScheduledMessages = { loadScheduledMessages(user) }, - onSendNow = { message -> - sendNow(message, user) - }, - onReschedule = { message, sendAt, sendWithoutNotification -> - reschedule(message, sendAt, sendWithoutNotification, user) - }, - onEdit = { message, sendAt -> - edit(message, sendAt, user) - }, - onDeleteScheduledMessage = { message -> deleteScheduledMessage(message, user) }, - onOpenParentMessage = { messageId -> - openParentMessage(messageId) - }, - onOpenThread = { threadId -> - openThread(threadId) - }, - threadTitle = threadTitle, - isThreadView = isThreadView, - onCopyScheduledMessage = { message -> - copyScheduledMessage(message) - } - ) - } + ScheduledMessagesScreen( + user = conversationUser, + conversationName = conversationName, + scheduledMessagesViewModel = scheduledMessagesViewModel, + dateUtils = dateUtils, + viewThemeUtils = viewThemeUtils, + onBack = { finish() }, + onLoadScheduledMessages = { loadScheduledMessages(conversationUser) }, + onSendNow = { message -> + sendNow(message, conversationUser) + }, + onReschedule = { message, sendAt, sendWithoutNotification -> + reschedule(message, sendAt, sendWithoutNotification, conversationUser) + }, + onEdit = { message, sendAt -> + edit(message, sendAt, conversationUser) + }, + onDeleteScheduledMessage = { message -> deleteScheduledMessage(message, conversationUser) }, + onOpenParentMessage = { messageId -> + openParentMessage(messageId) + }, + onOpenThread = { threadId -> + openThread(threadId) + }, + threadTitle = threadTitle, + isThreadView = isThreadView, + onCopyScheduledMessage = { message -> + copyScheduledMessage(message) + } + ) } // CompositionLocalProvider } } } private fun openThread(threadId: Long) { - val intent = Intent(this, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, roomToken) + val intent = ChatActivity.createIntent(this, conversationUser.id!!, roomToken).apply { putExtra(KEY_THREAD_ID, threadId) } startActivity(intent) @@ -981,17 +976,14 @@ class ScheduledMessagesActivity : BaseActivity() { } private fun openParentMessage(messageId: Long?) { - val intent = Intent(this, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, roomToken) - + val intent = ChatActivity.createIntent(this, conversationUser.id!!, roomToken).apply { messageId?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it.toString()) } } startActivity(intent) } private fun openThreadParentMessage(messageId: Long?, threadId: Long?) { - val intent = Intent(this, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, roomToken) + val intent = ChatActivity.createIntent(this, conversationUser.id!!, roomToken).apply { threadId?.let { putExtra(BundleKeys.KEY_THREAD_ID, it) } messageId?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it.toString()) } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index f475d2c762..7a059e30b1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -69,7 +69,11 @@ sealed interface MessageTypeContent { object RegularText : MessageTypeContent object SystemMessage : MessageTypeContent - data class LinkPreview(val url: String) : MessageTypeContent + /** + * [authHeader] authenticates images of the account's own server at [serverBaseUrl], e.g. reference previews. + */ + data class LinkPreview(val url: String, val serverBaseUrl: String? = null, val authHeader: String? = null) : + MessageTypeContent data class Media( val previewUrl: String?, @@ -79,7 +83,9 @@ sealed interface MessageTypeContent { val blurhash: String? = null, val width: Int? = null, val height: Int? = null, - val isClassified: Boolean = false + val isClassified: Boolean = false, + // Credentials of the account, as the preview is always loaded from its server. + val authHeader: String? = null ) : MessageTypeContent data class UploadingMedia( @@ -291,7 +297,13 @@ fun getMessageTypeContent(user: User, message: ChatMessage, isClassified: Boolea getDeckContent(message) } else { message.extractLinkPreviewUrl(user) - ?.let { MessageTypeContent.LinkPreview(url = it) } + ?.let { + MessageTypeContent.LinkPreview( + url = it, + serverBaseUrl = user.baseUrl, + authHeader = ApiUtils.getCredentials(user.username, user.token) + ) + } ?: MessageTypeContent.RegularText } @@ -332,7 +344,8 @@ fun getMediaContent(user: User, message: ChatMessage, isClassified: Boolean = fa blurhash = message.fileParameters.blurhash, width = message.fileParameters.width, height = message.fileParameters.height, - isClassified = isClassified + isClassified = isClassified, + authHeader = ApiUtils.getCredentials(user.username, user.token) ) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 5a3e643797..35d03c8dd4 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -70,6 +70,7 @@ import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.threadsoverview.data.ThreadsRepository import com.nextcloud.talk.ui.PlaybackSpeed +import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CharacterAvatarUtils @@ -81,7 +82,6 @@ import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.throttleLatest import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.message.SendMessageUtils import com.nextcloud.talk.utils.message.groupHashOf import com.nextcloud.talk.utils.preferences.AppPreferences @@ -272,8 +272,9 @@ class ChatViewModel @AssistedInject constructor( private val unifiedSearchRepository: UnifiedSearchRepository, private val mediaRecorderManager: MediaRecorderManager, private val audioFocusRequestManager: AudioFocusRequestManager, - private val currentUserProvider: CurrentUserProvider, + private val userManager: UserManager, @ApplicationScope private val appScope: CoroutineScope, + @Assisted private val initialUser: User, @Assisted private val chatRoomToken: String, @Assisted private val conversationThreadId: Long? ) : ViewModel(), @@ -317,8 +318,11 @@ class ChatViewModel @AssistedInject constructor( get() = results.getOrNull(selectedIndex) } - @Deprecated("use currentUserFlow") - lateinit var currentUser: User + /** + * The user of this chat with its latest stored values. + */ + val currentUser: User + get() = currentUserFlow.value ?: initialUser private var messageSearchHelper: MessageSearchHelper? = null private var searchRequestJob: Job? = null @@ -680,18 +684,20 @@ class ChatViewModel @AssistedInject constructor( // Current user flows // ------------------------------ private val currentUserFlow: StateFlow = - currentUserProvider.currentUserFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + userManager.userFlow(initialUser.id!!) + .stateIn(viewModelScope, SharingStarted.Eagerly, initialUser) private val nonNullUserFlow = currentUserFlow.filterNotNull() + // Emits once per bound user, so updates of the user row (e.g. capabilities) don't restart dependent flows. + private val boundUserFlow = nonNullUserFlow.distinctUntilChangedBy { it.id } + // Unlike conversationFlow below, this is not deduped by lastReadMessage/lastCommonReadMessage, // so it also reacts to fields those two ignore, e.g. hasCall (see [hasCall]). private val rawConversationFlow: Flow = - nonNullUserFlow + boundUserFlow .flatMapLatest { user -> - val userId = requireNotNull(user.id) - conversationRepository.observeConversation(userId, chatRoomToken) + conversationRepository.observeConversation(requireNotNull(user.id), chatRoomToken) } .mapNotNull { result -> when (result) { @@ -1149,7 +1155,7 @@ class ChatViewModel @AssistedInject constructor( } private fun observeLobbyState() { - val conversationFromDb = nonNullUserFlow + val conversationFromDb = boundUserFlow .flatMapLatest { user -> conversationRepository.observeConversation(requireNotNull(user.id), chatRoomToken) } @@ -1189,7 +1195,7 @@ class ChatViewModel @AssistedInject constructor( } private fun observePinnedMessage() { - nonNullUserFlow + boundUserFlow .flatMapLatest { user -> conversationRepository.observeConversation(requireNotNull(user.id), chatRoomToken) .mapNotNull { result -> @@ -1721,7 +1727,7 @@ class ChatViewModel @AssistedInject constructor( * - those get their avatar drawn on the client instead, see [CharacterAvatarUtils]. */ fun getAvatarUrl(message: ChatMessage): String = - if (this::currentUser.isInitialized && !message.hasClientSideAvatar()) { + if (!message.hasClientSideAvatar()) { ApiUtils.getUrlForAvatar( currentUser.baseUrl, message.actorId, @@ -1734,15 +1740,13 @@ class ChatViewModel @AssistedInject constructor( private fun ChatMessage.hasClientSideAvatar(): Boolean = CharacterAvatarUtils.avatarFor(actorType, actorId, actorDisplayName, guestLabel = null) != null - fun initData(user: User, credentials: String, urlForChatting: String, threadId: Long?) { - currentUser = user - + fun initData() { chatRepository.initData( - user, - credentials, - urlForChatting, + currentUser, + ApiUtils.getCredentials(currentUser.username, currentUser.token)!!, + ApiUtils.getUrlForChat(ApiUtils.API_V1, currentUser.baseUrl, chatRoomToken), chatRoomToken, - threadId + conversationThreadId ) observeConversationAndUserFirstTime() @@ -2099,10 +2103,6 @@ class ChatViewModel @AssistedInject constructor( * simply leaving the chat, would immediately mark everything read again. */ fun markChatAsUnread(lastReadMessage: Int) { - if (!this::currentUser.isInitialized) { - return - } - keepMarkedAsUnread = true localLastReadMessage = lastReadMessage resetUnreadMarkerCache() @@ -2120,9 +2120,6 @@ class ChatViewModel @AssistedInject constructor( } fun setChatReadMessage(lastReadMessage: Int) { - if (!this::currentUser.isInitialized) { - return - } // marking as read is the explicit counterpart of marking as unread and hands the read marker // back to the automatic handling keepMarkedAsUnread = false @@ -2373,6 +2370,7 @@ class ChatViewModel @AssistedInject constructor( val internalConversationId = "${currentUser.id}@$chatRoomToken" val workerId = UploadAndShareFilesWorker.upload( + userId = currentUser.id!!, fileUri = fileUri, roomToken = room, conversationName = displayName, @@ -2457,7 +2455,7 @@ class ChatViewModel @AssistedInject constructor( fun getMessageById(messageId: Long): Flow { val urlForChatting = ApiUtils.getUrlForChat( 1, // Keep API v1 for local message lookup until version wiring is centralized. - currentUser?.baseUrl, + currentUser.baseUrl, chatRoomToken ) @@ -2595,9 +2593,8 @@ class ChatViewModel @AssistedInject constructor( } } - suspend fun fetchOpenGraph(url: String): OpenGraphObjectDto? { - if (!this::currentUser.isInitialized) return null - return withContext(Dispatchers.IO) { + suspend fun fetchOpenGraph(url: String): OpenGraphObjectDto? = + withContext(Dispatchers.IO) { runCatching { chatNetworkDataSource.getOpenGraph( currentUser.getCredentials(), @@ -2606,7 +2603,6 @@ class ChatViewModel @AssistedInject constructor( )?.openGraphObject }.getOrNull() } - } suspend fun updateMessageDraft() { val model = conversationRepository.getLocallyStoredConversation( @@ -2829,6 +2825,6 @@ class ChatViewModel @AssistedInject constructor( @AssistedFactory interface ChatViewModelFactory { - fun build(roomToken: String, conversationThreadId: Long?): ChatViewModel + fun build(user: User, roomToken: String, conversationThreadId: Long?): ChatViewModel } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt index 95e5fa8b29..bd4b18f653 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ScheduledMessagesViewModel.kt @@ -16,8 +16,10 @@ import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.opengraph.ReferenceDto import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.message.SendMessageUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,12 +28,11 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -class ScheduledMessagesViewModel @Inject constructor( +class ScheduledMessagesViewModel @AssistedInject constructor( private val chatRepository: ChatMessageRepository, - private val currentUserProvider: CurrentUserProvider, - private val chatNetworkDataSource: ChatNetworkDataSource + private val chatNetworkDataSource: ChatNetworkDataSource, + @Assisted private val user: User ) : ViewModel() { sealed interface GetScheduledMessagesState @@ -53,9 +54,6 @@ class ScheduledMessagesViewModel @Inject constructor( data class ScheduledMessageErrorState(val error: Throwable? = null) : ScheduledMessageActionState - private val _currentUserState = MutableStateFlow(null) - val currentUserState: StateFlow = _currentUserState - sealed interface SendNowMessageState object SendNowMessageIdleState : SendNowMessageState object SendNowMessageLoadingState : SendNowMessageState @@ -117,7 +115,6 @@ class ScheduledMessagesViewModel @Inject constructor( viewModelScope.launch { try { - val user = _currentUserState.value ?: currentUserProvider.getCurrentUser().getOrNull() ?: return@launch if (user.baseUrl.isNullOrBlank()) { return@launch } @@ -143,13 +140,6 @@ class ScheduledMessagesViewModel @Inject constructor( } } - fun loadCurrentUser() { - viewModelScope.launch { - val user = currentUserProvider.getCurrentUser().getOrNull() - _currentUserState.value = user - } - } - @Suppress("LongParameterList") fun sendNow( credentials: String, @@ -219,9 +209,6 @@ class ScheduledMessagesViewModel @Inject constructor( private suspend fun getParentMessageById(token: String, parentMessageId: Long, threadId: Long?): ChatMessage? = withContext(Dispatchers.IO) { - val userResult = currentUserProvider.getCurrentUser() - val user = userResult.getOrElse { return@withContext null } - val credentials = user.getCredentials() val apiVersion = ApiUtils.getChatApiVersion( @@ -278,6 +265,11 @@ class ScheduledMessagesViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): ScheduledMessagesViewModel + } + companion object { private val TAG = ScheduledMessagesViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt index a162e45b87..e19a9d23bf 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ChooseAccountDialogCompose.kt @@ -65,7 +65,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.core.net.toUri +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import autodagger.AutoInjector import coil.compose.AsyncImage import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp @@ -82,6 +84,7 @@ import com.nextcloud.talk.chooseaccount.viewmodel.StatusUiState import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel @@ -90,12 +93,12 @@ import com.nextcloud.talk.models.json.status.StatusType import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.launch import java.net.CookieManager import javax.inject.Inject @@ -110,7 +113,7 @@ class ChooseAccountDialogCompose { lateinit var userManager: UserManager @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + lateinit var defaultAccountProvider: DefaultAccountProvider @Inject lateinit var cookieManager: CookieManager @@ -119,13 +122,13 @@ class ChooseAccountDialogCompose { lateinit var viewThemeUtils: ViewThemeUtils @Inject - lateinit var invitationsViewModel: InvitationsViewModel + lateinit var viewModelFactory: ViewModelProvider.Factory @Inject - lateinit var statusViewModel: StatusViewModel + lateinit var statusViewModelFactory: StatusViewModel.Factory @Inject - lateinit var statusMessageViewModel: StatusMessageViewModel + lateinit var statusMessageViewModelFactory: StatusMessageViewModel.Factory @Inject lateinit var networkMonitor: NetworkMonitor @@ -138,6 +141,23 @@ class ChooseAccountDialogCompose { @Suppress("LongMethod") fun GetChooseAccountDialog(shouldDismiss: MutableState, activity: Activity, showEcosystem: Boolean) { if (shouldDismiss.value) return + val currentUser = defaultAccountProvider.getDefaultUserBlocking()!! + // Scoped to the hosting activity, so they survive configuration changes and are cleared with it. The status + // view models are bound to the default account, so they are keyed by it and replaced when the default account + // changes while the activity stays open. + val invitationsViewModel: InvitationsViewModel = viewModel(factory = viewModelFactory) + val statusViewModel: StatusViewModel = viewModel( + key = "status-${currentUser.id}", + factory = ViewModelFactoryWithParams(StatusViewModel::class.java) { + statusViewModelFactory.build(currentUser) + } + ) + val statusMessageViewModel: StatusMessageViewModel = viewModel( + key = "statusMessage-${currentUser.id}", + factory = ViewModelFactoryWithParams(StatusMessageViewModel::class.java) { + statusMessageViewModelFactory.build(currentUser) + } + ) val colorScheme = viewThemeUtils.getColorScheme(activity) val status = remember { mutableStateOf(null) } val showOnlineStatusSheet = rememberSaveable { mutableStateOf(false) } @@ -146,7 +166,6 @@ class ChooseAccountDialogCompose { val statusViewState by statusViewModel.statusViewState.collectAsStateWithLifecycle() val invitationsStateByUser by invitationsViewModel.invitationsStateByUser.collectAsStateWithLifecycle() val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val currentUser = currentUserProvider.currentUser.blockingGet()!! val isStatusAvailable = CapabilitiesUtil.isUserStatusAvailable(currentUser) ecosystemManager = EcosystemManager(activity) @@ -190,7 +209,7 @@ class ChooseAccountDialogCompose { }, onOpenSettingsClick = { shouldDismiss.value = true - openSettings(activity) + openSettings(activity, currentUser) }, onEcosystemFilesClick = { shouldDismiss.value = true @@ -269,8 +288,9 @@ class ChooseAccountDialogCompose { activity.startActivity(intent) } - private fun openSettings(activity: Activity) { + private fun openSettings(activity: Activity, user: User) { val intent = Intent(activity, SettingsActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) activity.startActivity(intent) } @@ -299,9 +319,9 @@ class ChooseAccountDialogCompose { scope.launch { if (userManager.setUserAsActive(userItem.user)) { cookieManager.cookieStore.removeAll() - val intent = Intent(activity, ConversationsListActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - activity.startActivity(intent) + activity.startActivity( + ConversationsListActivity.createAccountSwitchIntent(activity, userItem.user.id!!) + ) onSelected() } } diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt index 1a26070a89..cf1ff7eb17 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt @@ -60,10 +60,16 @@ fun StatusMessageModalBottomSheet(currentStatus: StatusDto, viewModel: StatusMes var showEmojiPicker by rememberSaveable { mutableStateOf(false) } val backToStatus: () -> Unit = { showEmojiPicker = false } - LaunchedEffect(currentStatus) { - viewModel.init(currentStatus) - viewModel.checkBackupStatus() - viewModel.fetchPredefinedStatuses() + // The view model keeps unsaved edits across configuration changes, so it is only initialized when the sheet is + // opened, not again when it is recreated. + var isInitialized by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (!isInitialized) { + viewModel.init(currentStatus) + viewModel.checkBackupStatus() + viewModel.fetchPredefinedStatuses() + isInitialized = true + } } LaunchedEffect(isDismissed) { diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt index 4a172e73f0..5a4af66299 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusMessageViewModel.kt @@ -11,17 +11,19 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.chooseaccount.data.StatusRepository +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.status.ClearAtDto import com.nextcloud.talk.models.json.status.StatusDto import com.nextcloud.talk.models.json.status.predefined.PredefinedStatusDto import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Calendar -import javax.inject.Inject private const val ONE_SECOND_IN_MILLIS = 1000L private const val ONE_MINUTE_IN_SECONDS = 60L @@ -34,12 +36,11 @@ private const val LAST_SECOND_OF_MINUTE = 59 private const val HTTP_STATUS_CODE_OK = 200 private const val HTTP_STATUS_CODE_NOT_FOUND = 404 -class StatusMessageViewModel @Inject constructor( +class StatusMessageViewModel @AssistedInject constructor( private val repository: StatusRepository, - private val currentUserProvider: CurrentUserProviderOld + @Assisted private val currentUser: User ) : ViewModel() { - private val currentUser = currentUserProvider.currentUser.blockingGet() private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! private val _emoji = MutableStateFlow("") @@ -219,6 +220,11 @@ class StatusMessageViewModel @Inject constructor( clearAt = statusMessageClearAtFromPosition(position) } + @AssistedFactory + interface Factory { + fun build(user: User): StatusMessageViewModel + } + companion object { private val TAG = StatusMessageViewModel::class.simpleName const val CLEAR_AT_POS_DONT_CLEAR = 0 diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt index f1f0bbdb5b..6c5cf80955 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/viewmodel/StatusViewModel.kt @@ -10,23 +10,24 @@ package com.nextcloud.talk.chooseaccount.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.chooseaccount.data.StatusRepository +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.status.StatusOverall import com.nextcloud.talk.models.json.status.StatusType import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -class StatusViewModel @Inject constructor( +class StatusViewModel @AssistedInject constructor( private val repository: StatusRepository, - private val currentUserProvider: CurrentUserProviderOld, - private val logger: Logger + private val logger: Logger, + @Assisted private val currentUser: User ) : ViewModel() { - private val currentUser = currentUserProvider.currentUser.blockingGet() private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) private val _statusViewState = MutableStateFlow(StatusUiState.None) @@ -62,6 +63,11 @@ class StatusViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): StatusViewModel + } + companion object { private val TAG = StatusViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt index eacfc72f4e..30100da77a 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsActivity.kt @@ -9,34 +9,45 @@ package com.nextcloud.talk.contacts import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.utils.bundle.BundleKeys +import kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class ContactsActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory - private lateinit var contactsViewModel: ContactsViewModel + lateinit var viewModelFactory: ContactsViewModel.Factory + + private lateinit var user: User + + private val contactsViewModel: ContactsViewModel by assistedViewModels { viewModelFactory.build(user) } @SuppressLint("UnrememberedMutableState") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - contactsViewModel = ViewModelProvider(this, viewModelFactory)[ContactsViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return + observeCreatedRoom() setContent { val isAddParticipants = intent.getBooleanExtra(BundleKeys.KEY_ADD_PARTICIPANTS, false) val hideAlreadyAddedParticipants = intent.getBooleanExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, false) @@ -72,6 +83,24 @@ class ContactsActivity : BaseActivity() { } } } + + private fun observeCreatedRoom() { + lifecycleScope.launch { + // Only while visible: a conversation created meanwhile is still opened when the screen is started again. + repeatOnLifecycle(Lifecycle.State.STARTED) { + contactsViewModel.roomViewState.collect { state -> + if (state is ContactsViewModel.RoomUiState.Success) { + state.conversation?.token?.let { token -> + val chatIntent = ChatActivity.createIntent(this@ContactsActivity, state.userId, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } + contactsViewModel.clearRoomState() + } + } + } + } + } } class CompanionClass { diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt index e77ec253d4..6d403ea441 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsScreen.kt @@ -70,7 +70,7 @@ fun ContactsScreen(contactsViewModel: ContactsViewModel, uiState: ContactsViewMo .padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, paddingValues.calculateBottomPadding()) ) { if (!isAddParticipants) { - ConversationCreationOptions() + ConversationCreationOptions(contactsViewModel.currentUser.id!!) } ContactsList( diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt index 184a65550e..2dfc071829 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ContactsViewModel.kt @@ -13,22 +13,22 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.models.json.conversations.ConversationDto -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -class ContactsViewModel @Inject constructor( +@Suppress("TooManyFunctions") +class ContactsViewModel @AssistedInject constructor( private val repository: ContactsRepository, - private val currentUserProvider: CurrentUserProvider, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { private val _contactsViewState = MutableStateFlow(ContactsUiState.None) @@ -57,12 +57,10 @@ class ContactsViewModel @Inject constructor( private var hideAlreadyAddedParticipants: Boolean = false - private val currentUserFlow: StateFlow = - currentUserProvider.currentUserFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - - private val currentUser: User? - get() = currentUserFlow.value + @AssistedFactory + interface Factory { + fun build(user: User): ContactsViewModel + } companion object { private val TAG = ContactsViewModel::class.java.simpleName @@ -114,10 +112,6 @@ class ContactsViewModel @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun getContactsFromSearchParams(query: String = "") { val user = currentUser - if (user == null) { - _contactsViewState.value = ContactsUiState.Error("No current user") - return - } _contactsViewState.value = ContactsUiState.Loading viewModelScope.launch { try { @@ -150,10 +144,6 @@ class ContactsViewModel @Inject constructor( suspend fun getBlockingContactsFromSearchParams(query: String = "") = withContext(Dispatchers.IO) { val user = currentUser - if (user == null) { - _contactsViewState.value = ContactsUiState.Error("No current user") - return@withContext - } _contactsViewState.value = ContactsUiState.Loading try { val contacts = repository.getContacts( @@ -180,13 +170,16 @@ class ContactsViewModel @Inject constructor( } } + /** + * Resets [roomViewState] once its result was handled, so a recreated screen doesn't handle it again. + */ + fun clearRoomState() { + _roomViewState.value = RoomUiState.None + } + @Suppress("Detekt.TooGenericExceptionCaught") fun createRoom(roomType: String, sourceType: String?, userId: String, conversationName: String?) { val user = currentUser - if (user == null) { - _roomViewState.value = RoomUiState.Error("No current user") - return - } viewModelScope.launch { try { val room = repository.createRoom( @@ -198,7 +191,7 @@ class ContactsViewModel @Inject constructor( ) val conversation: ConversationDto? = room.ocs?.data - _roomViewState.value = RoomUiState.Success(conversation) + _roomViewState.value = RoomUiState.Success(user.id!!, conversation) } catch (exception: Exception) { logger.e(TAG, "Failed to create room", exception) _roomViewState.value = RoomUiState.Error(exception.message ?: "") @@ -207,14 +200,12 @@ class ContactsViewModel @Inject constructor( } fun getImageUri(avatarId: String, requestBigSize: Boolean, isDarkMode: Boolean): String = - currentUser?.let { - repository.getImageUri( - it, - avatarId, - requestBigSize, - isDarkMode - ) - } ?: "" + repository.getImageUri( + currentUser, + avatarId, + requestBigSize, + isDarkMode + ) sealed class ContactsUiState { data object None : ContactsUiState() @@ -225,7 +216,7 @@ class ContactsViewModel @Inject constructor( sealed class RoomUiState { data object None : RoomUiState() - data class Success(val conversation: ConversationDto?) : RoomUiState() + data class Success(val userId: Long, val conversation: ConversationDto?) : RoomUiState() data class Error(val message: String) : RoomUiState() } } diff --git a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt index a37193f260..db41618eae 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/components/ContactItemRow.kt @@ -9,8 +9,6 @@ package com.nextcloud.talk.contacts.components import android.content.Context -import android.content.Intent -import android.os.Bundle import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -36,13 +34,11 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.nextcloud.talk.R -import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.contacts.CompanionClass import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.bundle.BundleKeys @Suppress("LongMethod") @Composable @@ -98,15 +94,8 @@ fun ContactItemRow(contact: AutocompleteUserDto, contactsViewModel: ContactsView } } when (roomUiState) { - is ContactsViewModel.RoomUiState.Success -> { - val conversation = (roomUiState as ContactsViewModel.RoomUiState.Success).conversation - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversation?.token) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - context.startActivity(chatIntent) - } + // Opening the created conversation is handled once by ContactsActivity, not by every row. + is ContactsViewModel.RoomUiState.Success -> {} is ContactsViewModel.RoomUiState.Error -> { val errorMessage = (roomUiState as ContactsViewModel.RoomUiState.Error).message Box( diff --git a/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt b/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt index a5527eb5f3..f035cf4104 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/components/ConversationCreationOptions.kt @@ -34,9 +34,10 @@ import androidx.compose.ui.unit.sp import com.nextcloud.talk.R import com.nextcloud.talk.conversationcreation.ConversationCreationActivity import com.nextcloud.talk.openconversations.ListOpenConversationsActivity +import com.nextcloud.talk.utils.bundle.BundleKeys @Composable -fun ConversationCreationOptions() { +fun ConversationCreationOptions(userId: Long) { val context = LocalContext.current Column { Row( @@ -44,6 +45,7 @@ fun ConversationCreationOptions() { .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) .clickable { val intent = Intent(context, ConversationCreationActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) context.startActivity(intent) }, verticalAlignment = Alignment.CenterVertically @@ -70,6 +72,7 @@ fun ConversationCreationOptions() { .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .clickable { val intent = Intent(context, ListOpenConversationsActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) context.startActivity(intent) }, verticalAlignment = Alignment.CenterVertically @@ -98,7 +101,7 @@ fun ConversationCreationOptions() { fun ConversationCreationOptionsPreview() { MaterialTheme { Surface { - ConversationCreationOptions() + ConversationCreationOptions(userId = 1L) } } } diff --git a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt index e859da295c..89d965aed4 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt @@ -13,8 +13,8 @@ import androidx.lifecycle.viewModelScope import autodagger.AutoInjector import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chat.ChatMessageDto -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -24,9 +24,6 @@ import javax.inject.Inject class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource: ChatNetworkDataSource) : ViewModel() { - @Inject - lateinit var currentUserProvider: CurrentUserProvider - var threadId: String? = null private val _getContextChatMessagesState = @@ -34,17 +31,8 @@ class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource val getContextChatMessagesState: StateFlow = _getContextChatMessagesState @Suppress("LongParameterList") - fun getContextForChatMessages( - credentials: String, - baseUrl: String, - token: String, - threadId: String?, - messageId: String, - title: String - ) { + fun getContextForChatMessages(user: User, token: String, threadId: String?, messageId: String, title: String) { viewModelScope.launch { - val user = currentUserProvider.getCurrentUser().getOrThrow() - if (!user.hasSpreedFeatureCapability("chat-get-context") || !user.hasSpreedFeatureCapability("federation-v1") ) { @@ -53,8 +41,8 @@ class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource try { var messages = chatNetworkDataSource.getContextForChatMessage( - credentials = credentials, - baseUrl = baseUrl, + credentials = user.getCredentials(), + baseUrl = user.baseUrl!!, token = token, messageId = messageId, limit = LIMIT, diff --git a/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt index b2c872b3f8..95b6faa857 100644 --- a/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/conversation/RenameConversationDialogFragment.kt @@ -16,6 +16,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider @@ -28,10 +29,12 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogRenameConversationBinding import com.nextcloud.talk.events.ConversationsListFetchDataEvent import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import javax.inject.Inject @@ -54,6 +57,7 @@ class RenameConversationDialogFragment : DialogFragment() { private lateinit var binding: DialogRenameConversationBinding private lateinit var viewModel: ConversationInfoEditViewModel + private lateinit var user: User private var roomToken = "" private var initialName = "" @@ -61,8 +65,10 @@ class RenameConversationDialogFragment : DialogFragment() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] + user = BundleCompat.getParcelable(requireArguments(), KEY_USER, User::class.java)!! roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! initialName = arguments?.getString(INITIAL_NAME)!! } @@ -100,7 +106,7 @@ class RenameConversationDialogFragment : DialogFragment() { val positiveButton = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) positiveButton.isEnabled = false positiveButton.setOnClickListener { - viewModel.renameRoom(roomToken, binding.textEdit.text.toString()) + viewModel.renameRoom(user, roomToken, binding.textEdit.text.toString()) } themeDialog() @@ -185,12 +191,14 @@ class RenameConversationDialogFragment : DialogFragment() { */ companion object { private val TAG = RenameConversationDialogFragment::class.java.simpleName + private const val KEY_USER = "keyUser" private const val KEY_ROOM_TOKEN = "keyRoomToken" private const val INITIAL_NAME = "initialName" @JvmStatic - fun newInstance(roomTokenParam: String, initialName: String): RenameConversationDialogFragment { + fun newInstance(user: User, roomTokenParam: String, initialName: String): RenameConversationDialogFragment { val args = Bundle() + args.putParcelable(KEY_USER, user) args.putString(KEY_ROOM_TOKEN, roomTokenParam) args.putString(INITIAL_NAME, initialName) val fragment = RenameConversationDialogFragment() diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index e42ac91317..ab6dbd4998 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -83,7 +83,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import coil.compose.AsyncImage @@ -101,6 +100,7 @@ import com.nextcloud.talk.conversationcreation.ui.CreationResultEffect import com.nextcloud.talk.conversationcreation.ui.ShareCreatedConversation import com.nextcloud.talk.conversationcreation.ui.openConversation import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUserDto @@ -117,42 +117,30 @@ import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class ConversationCreationActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ConversationCreationViewModel.Factory + + private lateinit var user: User + + private val conversationCreationViewModel by assistedViewModels { viewModelFactory.build(user) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val conversationCreationViewModel = ViewModelProvider( - this, - viewModelFactory - )[ConversationCreationViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val context = LocalContext.current - val currentUser by conversationCreationViewModel.currentUser.collectAsState() MaterialTheme( colorScheme = colorScheme ) { - val user = currentUser - if (user == null) { - LoadingScreen() - } else { - val pickImage = remember(user) { PickImage(this@ConversationCreationActivity, user) } - ConversationCreationScreen(conversationCreationViewModel, context, pickImage) - } + val pickImage = remember(user) { PickImage(this@ConversationCreationActivity, user) } + ConversationCreationScreen(conversationCreationViewModel, context, pickImage) } } } } -@Composable -private fun LoadingScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - @Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -414,6 +402,10 @@ fun AddParticipants( .padding(start = 16.dp, bottom = 16.dp) .clickable { val intent = Intent(context, ContactsActivity::class.java) + intent.putExtra( + BundleKeys.KEY_INTERNAL_USER_ID, + conversationCreationViewModel.currentUser.id + ) intent.putParcelableArrayListExtra( "selectedParticipants", participants as ArrayList @@ -432,12 +424,8 @@ fun AddParticipants( } participants.toSet().forEach { participant -> Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - val avatarUser = conversationCreationViewModel.currentUser.value - val imageUri = if (avatarUser == null) { - null - } else { - participant.id?.let { avatarUri(avatarUser, it, DisplayUtils.isDarkModeOn(context)) } - } + val avatarUser = conversationCreationViewModel.currentUser + val imageUri = participant.id?.let { avatarUri(avatarUser, it, DisplayUtils.isDarkModeOn(context)) } val errorPlaceholderImage: Int = R.drawable.account_circle_96dp val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) AsyncImage( @@ -461,6 +449,10 @@ fun AddParticipants( .fillMaxWidth() .clickable { val intent = Intent(context, ContactsActivity::class.java) + intent.putExtra( + BundleKeys.KEY_INTERNAL_USER_ID, + conversationCreationViewModel.currentUser.id + ) intent.putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) intent.putExtra( BundleKeys.KEY_ONLY_LOCAL_PARTICIPANTS, @@ -797,7 +789,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo onHandled = { conversationCreationViewModel.clearCreationState() } ) - val currentUser by conversationCreationViewModel.currentUser.collectAsState() + val currentUser = conversationCreationViewModel.currentUser ShareCreatedConversation( roomToken = createdPublicConversation, password = password.takeIf { createdWithPassword }, @@ -806,7 +798,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo onDismiss = { roomToken -> createdPublicConversation = null conversationCreationViewModel.clearCreationState() - openConversation(context, roomToken) + currentUser.id?.let { openConversation(context, it, roomToken) } } ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt index fcc82073ad..e47b3812c5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt @@ -10,7 +10,6 @@ package com.nextcloud.talk.conversationcreation.ui import android.app.Activity import android.content.Context import android.content.Intent -import android.os.Bundle import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog @@ -28,7 +27,6 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ShareUtils -import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.copyPasswordToClipboard private val TAG = ConversationCreationActivity::class.java.simpleName @@ -69,7 +67,7 @@ fun CreationResultEffect( onHandled() } else { onHandled() - openConversation(context, roomToken) + openConversation(context, state.userId, roomToken) } } } @@ -128,11 +126,8 @@ fun ShareCreatedConversation( ) } -fun openConversation(context: Context, roomToken: String) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) +fun openConversation(context: Context, userId: Long, roomToken: String) { + val chatIntent = ChatActivity.createIntent(context, userId, roomToken) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) context.startActivity(chatIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index f7b9e47bfc..b562a5afd8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -33,30 +33,27 @@ import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -class ConversationCreationViewModel @Inject constructor( +class ConversationCreationViewModel @AssistedInject constructor( private val repository: ConversationCreationRepository, private val conversationCreator: ConversationCreator, private val passwordPolicyRepository: PasswordPolicyRepository, - currentUserProvider: CurrentUserProvider + @Assisted val currentUser: User ) : ViewModel() { private val _selectedParticipants = MutableStateFlow>(emptyList()) val selectedParticipants: StateFlow> = _selectedParticipants private val roomViewState = MutableStateFlow(RoomUIState.None) val creationState: StateFlow = roomViewState - val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { currentUser.value } + val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { currentUser } private val passwordGenerator = PasswordGenerator(passwordPolicyRepository) @@ -72,11 +69,8 @@ class ConversationCreationViewModel @Inject constructor( private val _isCreatingRoom = MutableStateFlow(false) val isCreatingRoom: StateFlow = _isCreatingRoom - val currentUser: StateFlow = currentUserProvider.currentUserFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val spreedCapabilities - get() = currentUser.value?.capabilities?.spreedCapability + get() = currentUser.capabilities?.spreedCapability val showPresetSelection: Boolean get() = CapabilitiesUtil.hasSpreedFeatureCapability( @@ -125,7 +119,7 @@ class ConversationCreationViewModel @Inject constructor( private var presetsJob: Job? = null val isLoadingPresets: Boolean - get() = currentUser.value == null || (showPresetSelection && _presets.value is PresetsUiState.Loading) + get() = showPresetSelection && _presets.value is PresetsUiState.Loading val pinnedParameters: Set get() = (_presets.value as? PresetsUiState.Success) @@ -135,11 +129,8 @@ class ConversationCreationViewModel @Inject constructor( .orEmpty() init { - viewModelScope.launch { - currentUser.filterNotNull().first() - if (showPresetSelection) { - loadPresets() - } + if (showPresetSelection) { + loadPresets() } } @@ -149,7 +140,7 @@ class ConversationCreationViewModel @Inject constructor( _presets.value = PresetsUiState.Loading presetsJob = viewModelScope.launch { try { - val user = currentUser.filterNotNull().first() + val user = currentUser val presets = repository.getConversationPresets( ApiUtils.getCredentials(user.username, user.token), ApiUtils.getUrlForConversationPresets(user.baseUrl) @@ -218,9 +209,8 @@ class ConversationCreationViewModel @Inject constructor( parametersChosenByUser.value += ConversationParameter.ROOM_TYPE to roomType recomputeParams() - val user = currentUser.value - if (allow && user != null && isPasswordEnforced && _password.value.isEmpty()) { - viewModelScope.launch { _password.value = passwordGenerator.generate(user) } + if (allow && isPasswordEnforced && _password.value.isEmpty()) { + viewModelScope.launch { _password.value = passwordGenerator.generate(currentUser) } } } @@ -252,8 +242,8 @@ class ConversationCreationViewModel @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun createRoomAndAddParticipants() { - val user = currentUser.value - if (_isCreatingRoom.value || user == null) { + val user = currentUser + if (_isCreatingRoom.value) { return } _isCreatingRoom.value = true @@ -277,7 +267,7 @@ class ConversationCreationViewModel @Inject constructor( ) val token = conversation?.token if (!token.isNullOrEmpty()) { - roomViewState.value = RoomUIState.Success(conversation) + roomViewState.value = RoomUIState.Success(user.id!!, conversation) } else { roomViewState.value = RoomUIState.Error() Log.e(TAG, "The created conversation came back without a token") @@ -298,6 +288,11 @@ class ConversationCreationViewModel @Inject constructor( roomViewState.value = RoomUIState.None } + @AssistedFactory + interface Factory { + fun build(user: User): ConversationCreationViewModel + } + companion object { private val TAG = ConversationCreationViewModel::class.simpleName } @@ -311,7 +306,7 @@ sealed interface PresetsUiState { sealed class RoomUIState { data object None : RoomUIState() - data class Success(val conversation: ConversationDto?) : RoomUIState() + data class Success(val userId: Long, val conversation: ConversationDto?) : RoomUIState() data class Error(val serverMessage: String? = null) : RoomUIState() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 255f89e1de..12b8ea0a0e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -128,18 +128,17 @@ class ConversationInfoActivity : BaseActivity() { lateinit var viewModel: ConversationInfoViewModel private lateinit var conversationToken: String - private var conversationUser: User? = null + private lateinit var conversationUser: User private lateinit var credentials: String private var startGroupChat: Boolean = false private val workerData: Data? get() { - val user = conversationUser ?: return null return if (conversationToken.isNotEmpty()) { Data.Builder() .putString(KEY_ROOM_TOKEN, conversationToken) - .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id!!) .build() } else { null @@ -153,10 +152,9 @@ class ConversationInfoActivity : BaseActivity() { val selectedAutocompleteUsers = intent?.getParcelableArrayListExtraProvider("selectedParticipants") ?: emptyList() - val user = conversationUser ?: return@executeIfResultOk if (startGroupChat) { viewModel.createRoomFromOneToOne( - user, + conversationUser, viewModel.uiState.value.participants.map { it.participant }, selectedAutocompleteUsers, conversationToken @@ -171,13 +169,14 @@ class ConversationInfoActivity : BaseActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { - conversationUser?.let { viewModel.getRoom(it, conversationToken) } + viewModel.getRoom(conversationUser, conversationToken) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + conversationUser = setUpBoundUserOrFinish() ?: return conversationToken = requireNotNull( intent.getStringExtra(KEY_ROOM_TOKEN) @@ -193,20 +192,10 @@ class ConversationInfoActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] - lifecycleScope.launch { - currentUserProvider.getCurrentUser() - .onSuccess { user -> - conversationUser = user - credentials = ApiUtils.getCredentials(user.username, user.token)!! - viewModel.getRoom(user, conversationToken) - if (upcomingEventSummary != null || upcomingEventTime != null) { - viewModel.setUpcomingEvent(upcomingEventSummary, upcomingEventTime) - } - } - .onFailure { - Log.e(TAG, "Failed to get current user") - finish() - } + credentials = ApiUtils.getCredentials(conversationUser.username, conversationUser.token)!! + viewModel.getRoom(conversationUser, conversationToken) + if (upcomingEventSummary != null || upcomingEventTime != null) { + viewModel.setUpcomingEvent(upcomingEventSummary, upcomingEventTime) } setupCompose() @@ -224,7 +213,7 @@ class ConversationInfoActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.MAIN) fun onMessageEvent(eventStatus: EventStatus) { - conversationUser?.let { viewModel.loadParticipants(it, conversationToken) } + viewModel.loadParticipants(conversationUser, conversationToken) } private fun setupCompose() { @@ -235,7 +224,6 @@ class ConversationInfoActivity : BaseActivity() { LaunchedEffect(Unit) { viewModel.uiEvent.collect { event -> - val user = conversationUser ?: return@collect when (event) { is ConversationInfoUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(getString(event.resId)) @@ -243,13 +231,15 @@ class ConversationInfoActivity : BaseActivity() { snackbarHostState.showSnackbar(event.text) is ConversationInfoUiEvent.NavigateToChat -> { startActivity( - Intent(this@ConversationInfoActivity, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, event.token) - } + ChatActivity.createIntent( + this@ConversationInfoActivity, + conversationUser.id!!, + event.token + ) ) } ConversationInfoUiEvent.RefreshParticipants -> - viewModel.loadParticipants(user, conversationToken) + viewModel.loadParticipants(conversationUser, conversationToken) } } } @@ -283,20 +273,22 @@ class ConversationInfoActivity : BaseActivity() { } private fun onGuestPasswordSave(request: PasswordRequest, password: String, copyAfterSave: Boolean) { - val user = conversationUser ?: return if (copyAfterSave) { copyPassword(password) } when (request) { PasswordRequest.SET -> { - val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + val apiVersion = ApiUtils.getConversationApiVersion( + conversationUser, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) viewModel.setPassword( - user = user, - url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), + user = conversationUser, + url = ApiUtils.getUrlForRoomPassword(apiVersion, conversationUser.baseUrl!!, conversationToken), password = password ) } - PasswordRequest.ALLOW_GUESTS -> viewModel.allowGuests(user, conversationToken, true, password) + PasswordRequest.ALLOW_GUESTS -> viewModel.allowGuests(conversationUser, conversationToken, true, password) } } @@ -322,6 +314,7 @@ class ConversationInfoActivity : BaseActivity() { onEditConversation = { editConversationResult.launch( Intent(this, ConversationInfoEditActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(KEY_ROOM_TOKEN, conversationToken) } ) @@ -329,60 +322,58 @@ class ConversationInfoActivity : BaseActivity() { onMessageNotificationLevelClick = { showNotificationLevelDialog() }, onCallNotificationsClick = { viewModel.toggleCallNotifications() }, onImportantConversationClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks - viewModel.toggleImportantConversation(credentials, user.baseUrl!!, conversationToken) + viewModel.toggleImportantConversation(credentials, conversationUser.baseUrl!!, conversationToken) }, onSensitiveConversationClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks - viewModel.toggleSensitiveConversation(credentials, user.baseUrl!!, conversationToken) + viewModel.toggleSensitiveConversation(credentials, conversationUser.baseUrl!!, conversationToken) }, - onLobbyClick = { conversationUser?.let { viewModel.toggleLobby(it, conversationToken) } }, + onLobbyClick = { viewModel.toggleLobby(conversationUser, conversationToken) }, onLobbyTimerClick = { showLobbyTimerDialog() }, onAllowGuestsClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks val state = viewModel.uiState.value val allow = !state.guestsAllowed if (allow && isPasswordEnforced() && !state.hasPassword) { onPasswordRequest(PasswordRequest.ALLOW_GUESTS) } else { - viewModel.allowGuests(user, conversationToken, allow) + viewModel.allowGuests(conversationUser, conversationToken, allow) } }, onPasswordProtectionClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks if (!viewModel.uiState.value.hasPassword) { onPasswordRequest(PasswordRequest.SET) } else { val apiVersion = - ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + ApiUtils.getConversationApiVersion( + conversationUser, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) viewModel.setPassword( - user = user, - url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), + user = conversationUser, + url = ApiUtils.getUrlForRoomPassword(apiVersion, conversationUser.baseUrl!!, conversationToken), password = "" ) } }, onResendInvitationsClick = { - conversationUser?.let { viewModel.resendInvitations(it, conversationToken) } + viewModel.resendInvitations(conversationUser, conversationToken) }, onSharedItemsClick = { showSharedItems() }, onThreadsClick = { openThreadsOverview() }, onRecordingConsentClick = { - conversationUser?.let { viewModel.toggleRecordingConsent(it, conversationToken) } + viewModel.toggleRecordingConsent(conversationUser, conversationToken) }, onMessageExpirationClick = { showMessageExpirationDialog() }, onShareConversationClick = { - val user = conversationUser ?: return@ConversationInfoScreenCallbacks val state = viewModel.uiState.value ShareUtils.shareConversationLink( this, - user.baseUrl, + conversationUser.baseUrl, state.conversationToken, - CapabilitiesUtil.canGeneratePrettyURL(user) + CapabilitiesUtil.canGeneratePrettyURL(conversationUser) ) }, onLockConversationClick = { - conversationUser?.let { viewModel.toggleLock(it, conversationToken) } + viewModel.toggleLock(conversationUser, conversationToken) }, onParticipantClick = { model -> handleParticipantClick(model) }, onParticipantOpsDismiss = { viewModel.setParticipantForOps(null) }, @@ -396,7 +387,7 @@ class ConversationInfoActivity : BaseActivity() { selectParticipantsToAdd() }, onListBansClick = { listBans() }, - onArchiveClick = { conversationUser?.let { viewModel.toggleArchive(it, conversationToken) } }, + onArchiveClick = { viewModel.toggleArchive(conversationUser, conversationToken) }, onLeaveConversationClick = { leaveConversation() }, onClearHistoryClick = { showClearHistoryDialog() }, onDeleteConversationClick = { showDeleteConversationDialog() } @@ -406,6 +397,7 @@ class ConversationInfoActivity : BaseActivity() { val conv = viewModel.uiState.value.conversation ?: return startActivity( Intent(this, SharedItemsActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(BundleKeys.KEY_CONVERSATION_NAME, conv.displayName) putExtra(KEY_ROOM_TOKEN, conversationToken) putExtra( @@ -421,14 +413,14 @@ class ConversationInfoActivity : BaseActivity() { } fun openThreadsOverview() { - val user = conversationUser ?: return val threadsUrl = ApiUtils.getUrlForRecentThreads( version = 1, - baseUrl = user.baseUrl, + baseUrl = conversationUser.baseUrl, token = conversationToken ) startActivity( Intent(context, ThreadsOverviewActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(KEY_ROOM_TOKEN, conversationToken) putExtra(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads)) putExtra(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) @@ -437,7 +429,6 @@ class ConversationInfoActivity : BaseActivity() { } private fun showLobbyTimerDialog() { - val user = conversationUser ?: return val currentLobbyTimer = viewModel.uiState.value.conversation?.lobbyTimer ?: 0L MaterialDialog(this, BottomSheet(WRAP_CONTENT)).show { val cal = Calendar.getInstance() @@ -445,7 +436,7 @@ class ConversationInfoActivity : BaseActivity() { datePicker { _, date -> showTimePicker(date) { selectedDate -> viewModel.setLobbyTimerAndSubmit( - user, + conversationUser, conversationToken, selectedDate.timeInMillis / DateConstants.SECOND_DIVIDER ) @@ -514,7 +505,7 @@ class ConversationInfoActivity : BaseActivity() { private fun listBans() { val transaction = supportFragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - transaction.add(android.R.id.content, DialogBanListFragment(conversationToken)) + transaction.add(android.R.id.content, DialogBanListFragment.newInstance(conversationToken, conversationUser)) .addToBackStack(null) .commit() } @@ -540,6 +531,7 @@ class ConversationInfoActivity : BaseActivity() { } addParticipantsResult.launch( Intent(this, ContactsActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) putExtra(BundleKeys.KEY_ADD_PARTICIPANTS, true) putParcelableArrayListExtra("selectedParticipants", existingParticipants) putExtra(KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS, true) @@ -550,7 +542,6 @@ class ConversationInfoActivity : BaseActivity() { } private fun addParticipantsToConversation(autocompleteUsers: List) { - val user = conversationUser ?: return val groupIds = mutableSetOf() val emailIds = mutableSetOf() val circleIds = mutableSetOf() @@ -566,7 +557,7 @@ class ConversationInfoActivity : BaseActivity() { } val data = Data.Builder() - .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id!!) .putString(BundleKeys.KEY_TOKEN, conversationToken) .putStringArray(BundleKeys.KEY_SELECTED_USERS, userIds.toTypedArray()) .putStringArray(BundleKeys.KEY_SELECTED_GROUPS, groupIds.toTypedArray()) @@ -585,7 +576,7 @@ class ConversationInfoActivity : BaseActivity() { .observe(this) { workInfo: WorkInfo? -> when (workInfo?.state) { WorkInfo.State.SUCCEEDED -> { - viewModel.loadParticipants(user, conversationToken) + viewModel.loadParticipants(conversationUser, conversationToken) reportParticipantsNotAdded(workInfo.outputData, names) } WorkInfo.State.FAILED -> reportParticipantsNotAdded(workInfo.outputData, names) @@ -625,7 +616,7 @@ class ConversationInfoActivity : BaseActivity() { if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { - conversationUser?.id?.let { userId -> + conversationUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( context, conversationToken, @@ -678,10 +669,12 @@ class ConversationInfoActivity : BaseActivity() { } private fun clearHistory() { - val user = conversationUser ?: return val caps = viewModel.uiState.value.spreedCapabilities ?: return val apiVersion = ApiUtils.getChatApiVersion(caps, intArrayOf(1)) - viewModel.clearChatHistory(user, ApiUtils.getUrlForChat(apiVersion, user.baseUrl!!, conversationToken)) + viewModel.clearChatHistory( + conversationUser, + ApiUtils.getUrlForChat(apiVersion, conversationUser.baseUrl!!, conversationToken) + ) } private fun deleteConversation() { @@ -693,7 +686,7 @@ class ConversationInfoActivity : BaseActivity() { .build() ) - conversationUser?.id?.let { userId -> + conversationUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( context, conversationToken, @@ -709,14 +702,13 @@ class ConversationInfoActivity : BaseActivity() { } private fun toggleModeratorStatus(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser ?: return val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR || participant.type == ParticipantDto.ParticipantType.GUEST_MODERATOR ) { ncApi.demoteAttendeeFromModerator( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.attendeeId, null )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) @@ -725,7 +717,7 @@ class ConversationInfoActivity : BaseActivity() { ) { ncApi.promoteAttendeeToModerator( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.attendeeId, null )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) @@ -733,18 +725,17 @@ class ConversationInfoActivity : BaseActivity() { } private fun toggleModeratorStatusLegacy(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser ?: return val subscriber = participantActionObserver() if (participant.type == ParticipantDto.ParticipantType.MODERATOR) { ncApi.demoteModeratorToUser( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.userId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } else if (participant.type == ParticipantDto.ParticipantType.USER) { ncApi.promoteUserToModerator( credentials, - ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.userId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(subscriber) } @@ -754,7 +745,7 @@ class ConversationInfoActivity : BaseActivity() { object : Observer { override fun onSubscribe(d: Disposable) { /* unused */ } override fun onNext(genericOverall: GenericOverall) { - conversationUser?.let { viewModel.loadParticipants(it, conversationToken) } + viewModel.loadParticipants(conversationUser, conversationToken) } @SuppressLint("LongLogTag") @@ -766,12 +757,11 @@ class ConversationInfoActivity : BaseActivity() { } private fun removeAttendeeFromConversation(apiVersion: Int, participant: ParticipantDto) { - val user = conversationUser ?: return val observer = participantActionObserver() if (apiVersion >= ApiUtils.API_V4) { ncApi.removeAttendeeFromConversation( credentials, - ApiUtils.getUrlForAttendees(apiVersion, user.baseUrl!!, conversationToken), + ApiUtils.getUrlForAttendees(apiVersion, conversationUser.baseUrl!!, conversationToken), participant.attendeeId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } else { @@ -780,13 +770,21 @@ class ConversationInfoActivity : BaseActivity() { ) { ncApi.removeParticipantFromConversation( credentials, - ApiUtils.getUrlForRemovingParticipantFromConversation(user.baseUrl!!, conversationToken, true), + ApiUtils.getUrlForRemovingParticipantFromConversation( + conversationUser.baseUrl!!, + conversationToken, + true + ), participant.sessionId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } else { ncApi.removeParticipantFromConversation( credentials, - ApiUtils.getUrlForRemovingParticipantFromConversation(user.baseUrl!!, conversationToken, false), + ApiUtils.getUrlForRemovingParticipantFromConversation( + conversationUser.baseUrl!!, + conversationToken, + false + ), participant.userId )?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread())?.subscribe(observer) } @@ -794,7 +792,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun banActor(actorType: String, actorId: String, internalNote: String) { - conversationUser?.let { viewModel.banActor(it, conversationToken, actorType, actorId, internalNote) } + viewModel.banActor(conversationUser, conversationToken, actorType, actorId, internalNote) } private fun handleParticipantClick(model: ParticipantModel) { @@ -807,9 +805,8 @@ class ConversationInfoActivity : BaseActivity() { val conv = state.conversation ?: return val caps = state.spreedCapabilities ?: return if (!ConversationUtils.canModerate(conv, caps)) return - val user = conversationUser ?: return val participant = model.participant - val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) when (action) { ParticipantOpsAction.PromoteToModerator, ParticipantOpsAction.DemoteFromModerator -> @@ -866,8 +863,7 @@ class ConversationInfoActivity : BaseActivity() { promote: Boolean, participantType: Int ) { - val user = conversationUser ?: return - val url = ApiUtils.getUrlForRoomModerators(apiVersion, user.baseUrl!!, conversationToken) + val url = ApiUtils.getUrlForRoomModerators(apiVersion, conversationUser.baseUrl!!, conversationToken) val call = if (promote) { ncApi.promoteAttendeeToModerator(credentials, url, participant.attendeeId, participantType) } else { @@ -879,12 +875,11 @@ class ConversationInfoActivity : BaseActivity() { } private fun handleBan(participant: ParticipantDto) { - val user = conversationUser ?: return - val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, 1)) + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4, 1)) val dialogBinding = DialogBanParticipantBinding.inflate(layoutInflater) val dialog = MaterialAlertDialogBuilder(this).setView(dialogBinding.root).create() dialogBinding.avatarImage.loadUserAvatar( - user, + conversationUser, participant.actorId!!, requestBigSize = true, ignoreCache = false diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index 529728d890..52a24a0cba 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.res.stringResource import androidx.core.net.toFile import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.github.dhaval2404.imagepicker.ImagePicker import com.nextcloud.talk.activities.BaseActivity @@ -33,9 +32,6 @@ import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditVie import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.launch @AutoInjector(NextcloudTalkApplication::class) class ConversationInfoEditActivity : BaseActivity() { @@ -75,26 +71,17 @@ class ConversationInfoEditActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val user = setUpBoundUserOrFinish() ?: return + // Created right away, so results of the camera or an image picker are handled even when the activity was + // recreated after process death and the conversation is still loading. + pickImage = PickImage(this, user) val roomToken = intent.extras?.getString(BundleKeys.KEY_ROOM_TOKEN)!! conversationInfoEditViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoEditViewModel::class.java] - conversationInfoEditViewModel.initialize(roomToken) - - conversationInfoEditViewModel.uiState.value.conversationUser?.let { user -> - pickImage = PickImage(this, user) - } - - if (pickImage == null) { - lifecycleScope.launch { - val user = conversationInfoEditViewModel.uiState - .mapNotNull { it.conversationUser } - .first() - pickImage = PickImage(this@ConversationInfoEditActivity, user) - } - } + conversationInfoEditViewModel.initialize(user, roomToken) setupCompose() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt index 1f17dc70d4..944b1a3339 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt @@ -16,7 +16,6 @@ import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,7 +26,6 @@ import javax.inject.Inject class ConversationInfoEditViewModel @Inject constructor( private val conversationInfoEditRepository: ConversationInfoEditRepository, - private val currentUserProvider: CurrentUserProvider, private val logger: Logger ) : ViewModel() { @@ -38,9 +36,10 @@ class ConversationInfoEditViewModel @Inject constructor( private var initialized = false private var currentUser: User? = null - fun initialize(token: String) { + fun initialize(user: User, token: String) { if (initialized) return initialized = true + currentUser = user roomToken = token loadRoom() } @@ -66,8 +65,7 @@ class ConversationInfoEditViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { try { - val user = currentUserProvider.getCurrentUser().getOrThrow() - currentUser = user + val user = checkNotNull(currentUser) val roomData = conversationInfoEditRepository.getRoom(user, roomToken) val conversationModel = roomData.conversation val isEvent = conversationModel.objectType == ConversationEnums.ObjectType.EVENT @@ -178,10 +176,9 @@ class ConversationInfoEditViewModel @Inject constructor( } @Suppress("Detekt.TooGenericExceptionCaught") - fun renameRoom(token: String, newName: String) { + fun renameRoom(user: User, token: String, newName: String) { viewModelScope.launch { try { - val user = currentUser ?: currentUserProvider.getCurrentUser().getOrThrow() conversationInfoEditRepository.renameConversation(user, token, newName) _uiState.update { it.copy(navigateBack = true) } } catch (exception: Exception) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index d27efe8209..645dcc9c25 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -11,6 +11,7 @@ import android.annotation.SuppressLint import android.app.NotificationManager import android.content.ActivityNotFoundException import android.content.ContentResolver +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -30,10 +31,10 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -79,7 +80,6 @@ import com.nextcloud.talk.ui.dialog.FilterConversationFragment import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.BrandingUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -130,8 +130,8 @@ import javax.inject.Inject @Suppress("LargeClass", "TooManyFunctions", "NestedBlockDepth") class ConversationsListActivity : BaseActivity() { - @Inject - lateinit var userManager: UserManager + // Started without an account on app start, when sharing from other apps and after login. + override val allowsDefaultAccount = true @Inject lateinit var ncApiCoroutines: NcApiCoroutines @@ -140,19 +140,28 @@ class ConversationsListActivity : BaseActivity() { lateinit var platformPermissionUtil: PlatformPermissionUtil @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var conversationsListViewModelFactory: ConversationsListViewModel.Factory + + @Inject + lateinit var conversationTagsViewModelFactory: ConversationTagsViewModel.Factory @Inject lateinit var networkMonitor: NetworkMonitor @Inject - lateinit var contactsViewModel: ContactsViewModel + lateinit var contactsViewModelFactory: ContactsViewModel.Factory - lateinit var conversationsListViewModel: ConversationsListViewModel + val contactsViewModel: ContactsViewModel by assistedViewModels { contactsViewModelFactory.build(currentUser) } - lateinit var conversationTagsViewModel: ConversationTagsViewModel + val conversationsListViewModel: ConversationsListViewModel by assistedViewModels { + conversationsListViewModelFactory.build(currentUser) + } - private var currentUser: User? = null + val conversationTagsViewModel: ConversationTagsViewModel by assistedViewModels { + conversationTagsViewModelFactory.build(currentUser) + } + + private lateinit var currentUser: User private val snackbarHostState = SnackbarHostState() private val isMaintenanceModeState = MutableStateFlow(false) private val showUnreadBubbleState = MutableStateFlow(false) @@ -196,18 +205,14 @@ class ConversationsListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + currentUser = setUpBoundUserOrFinish() ?: return ecosystemManager = EcosystemManager(this@ConversationsListActivity) - val targetUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) - currentUser = if (targetUserId != 0L) { - runBlocking { userManager.getUserWithId(targetUserId) }!! - } else { - currentUserProviderOld.currentUser.blockingGet() + if (!currentUser.current) { + // The shown account becomes the last used one, which the account switcher and status views rely on. + lifecycleScope.launch { userManager.setUserAsActive(currentUser) } } - conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] - conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] - setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) if (savedInstanceState != null) { @@ -237,6 +242,12 @@ class ConversationsListActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + val newUserId = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + if (newUserId != 0L && newUserId != currentUser.id) { + relaunchForAccount(newUserId, intent) + return + } + currentUser.id?.let { intent.putExtra(KEY_INTERNAL_USER_ID, it) } setIntent(intent) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) @@ -310,12 +321,20 @@ class ConversationsListActivity : BaseActivity() { bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) val settingsIntent = Intent(context, SettingsActivity::class.java) settingsIntent.putExtras(bundle) + settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(settingsIntent) }, - onFederationHintClick = { startActivity(Intent(context, InvitationsActivity::class.java)) }, + onFederationHintClick = { + val intent = Intent(context, InvitationsActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) + startActivity(intent) + }, onFilterClick = { FilterConversationFragment - .newInstance(conversationsListViewModel.filterStateFlow.value.toMutableMap()) + .newInstance( + conversationsListViewModel.filterStateFlow.value.toMutableMap(), + conversationsListViewModel.currentUser + ) .show(supportFragmentManager, FilterConversationFragment.TAG) }, onThreadsClick = { openFollowedThreadsOverview() }, @@ -323,12 +342,14 @@ class ConversationsListActivity : BaseActivity() { if (resources.getBoolean(R.bool.multiaccount_support)) { showChooseAccountDialog() } else { - startActivity(Intent(context, SettingsActivity::class.java)) + val settingsIntent = Intent(context, SettingsActivity::class.java) + settingsIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) + startActivity(settingsIntent) } }, onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, onAccountChooserClick = { - ChooseAccountShareToDialogFragment.newInstance() + ChooseAccountShareToDialogFragment.newInstance(currentUser) .show(supportFragmentManager, ChooseAccountShareToDialogFragment.TAG) }, onNewConversation = { showNewConversationsScreen() }, @@ -350,10 +371,8 @@ class ConversationsListActivity : BaseActivity() { val user = users.firstOrNull { user -> user.username == trimmedAccountName && baseUrl == user.baseUrl?.toUri()?.host } - if (user != null) { - userManager.setUserAsActive(user) - val intent = Intent(context, ConversationsListActivity::class.java) - startActivity(intent) + if (user != null && userManager.setUserAsActive(user)) { + startActivity(createAccountSwitchIntent(context, user.id!!)) } else { showSnackbar(getString(R.string.nc_no_account_found)) } @@ -390,6 +409,18 @@ class ConversationsListActivity : BaseActivity() { return usesPush } + override fun onRestart() { + super.onRestart() + // The list shows the default account. If another account became the default while the list was in the + // background (e.g. by joining a call for it), switch to that account. Not while forwarding or sharing, which + // would lose the pending message. + val defaultUserId = defaultAccountProvider.getDefaultUserBlocking()?.id ?: return + val isPickingConversation = forwardMessage || hasActivityActionSendIntent() + if (defaultUserId != currentUser.id && !isPickingConversation) { + startActivity(createAccountSwitchIntent(this, defaultUserId)) + } + } + override fun onResume() { super.onResume() @@ -415,27 +446,22 @@ class ConversationsListActivity : BaseActivity() { eventBus.register(this) } - if (currentUser != null) { - if (isServerEOL(currentUser!!.serverVersion?.major)) { - showServerEOLDialog() - return - } - credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + if (isServerEOL(currentUser.serverVersion?.major)) { + showServerEOLDialog() + return + } + credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) - if (currentUser!!.id != appPreferences.getConversationListLastUserId()) { - appPreferences.setConversationListPositionAndOffset(0, 0) - } + if (currentUser.id != appPreferences.getConversationListLastUserId()) { + appPreferences.setConversationListPositionAndOffset(0, 0) + } - lifecycleScope.launch { - hasMultipleAccountsState.value = userManager.getUsers().size > 1 - } - conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) - fetchRooms() - fetchPendingInvitations() - } else { - logger.e(TAG, "currentUser was null") - showSnackbar(getString(R.string.nc_common_error_sorry)) + lifecycleScope.launch { + hasMultipleAccountsState.value = userManager.getUsers().size > 1 } + conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) + fetchRooms() + fetchPendingInvitations() conversationsListViewModel.checkIfThreadsExist() conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) @@ -448,7 +474,7 @@ class ConversationsListActivity : BaseActivity() { val firstOffset = state.layoutInfo.visibleItemsInfo.firstOrNull()?.offset ?: 0 appPreferences.setConversationListPositionAndOffset(state.firstVisibleItemIndex, firstOffset) } - appPreferences.setConversationListLastUserId(currentUser?.id ?: -1L) + appPreferences.setConversationListLastUserId(currentUser.id ?: -1L) } @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -482,14 +508,8 @@ class ConversationsListActivity : BaseActivity() { handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "") // Update Direct Share targets - if (currentUser != null) { - lifecycleScope.launch { - DirectShareHelper.publishShareTargetShortcuts( - context, - currentUser!!, - list - ) - } + lifecycleScope.launch { + DirectShareHelper.publishShareTargetShortcuts(context, currentUser, list) } // check for Direct Share @@ -504,9 +524,7 @@ class ConversationsListActivity : BaseActivity() { } // Update dynamic shortcuts for frequent/favorite conversations - currentUser?.let { user -> - ShortcutManagerHelper.updateDynamicShortcuts(context, list, user) - } + ShortcutManagerHelper.updateDynamicShortcuts(context, list, currentUser) if (!scrollPositionRestored) { scrollPositionRestored = true @@ -522,13 +540,12 @@ class ConversationsListActivity : BaseActivity() { contactsViewModel.roomViewState.onEach { state -> when (state) { is ContactsViewModel.RoomUiState.Success -> { - val conversation = state.conversation - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, conversation?.token) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) + state.conversation?.token?.let { token -> + val chatIntent = ChatActivity.createIntent(context, state.userId, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } + contactsViewModel.clearRoomState() } else -> {} @@ -599,11 +616,8 @@ class ConversationsListActivity : BaseActivity() { private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { if (noteToSelfAvailable) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, noteToSelfToken) - bundle.putBoolean(BundleKeys.KEY_FOCUS_INPUT, true) - val intent = Intent(context, ChatActivity::class.java) - intent.putExtras(bundle) + val intent = ChatActivity.createIntent(context, currentUser.id!!, noteToSelfToken) + intent.putExtra(BundleKeys.KEY_FOCUS_INPUT, true) intent.action = Intent.ACTION_VIEW val openNotesString = resources.getString(R.string.open_notes) @@ -646,8 +660,7 @@ class ConversationsListActivity : BaseActivity() { return } val threadId = result.threadId?.toLongOrNull()?.takeIf { it > 0L } - val intent = Intent(context, ChatActivity::class.java).apply { - putExtra(KEY_ROOM_TOKEN, result.conversationToken) + val intent = ChatActivity.createIntent(context, currentUser.id!!, result.conversationToken).apply { putExtra(BundleKeys.KEY_MESSAGE_ID, messageId) putExtra(BundleKeys.KEY_SEARCH_QUERY, result.searchTerm) threadId?.let { putExtra(BundleKeys.KEY_THREAD_ID, it) } @@ -663,6 +676,28 @@ class ConversationsListActivity : BaseActivity() { showAccountDialogState.value = true } + /** + * Replaces this list by a new one for the account with the internal id [userId], e.g. for an intent for another + * account or to continue a pending share with another account. + * + * The list is bound to its account, so it is not recreated, which would keep the account and its view models. + * The new list gets the action, data and extras of [source], including shared content and the read access to + * shared files, which ends with this activity. The activity is exported, so [source] can come from another app: + * the new intent only targets this activity and takes over no other flags. + */ + fun relaunchForAccount(userId: Long, source: Intent = intent) { + val accountIntent = Intent(this, ConversationsListActivity::class.java).apply { + action = source.action + setDataAndType(source.data, source.type) + source.extras?.let { putExtras(it) } + clipData = source.clipData + putExtra(KEY_INTERNAL_USER_ID, userId) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(accountIntent) + finish() + } + private fun hasActivityActionSendIntent(): Boolean = Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action @@ -671,11 +706,11 @@ class ConversationsListActivity : BaseActivity() { } fun fetchRooms() { - conversationsListViewModel.getRooms(currentUser!!) + conversationsListViewModel.getRooms(currentUser) } private fun fetchPendingInvitations() { - if (hasSpreedFeatureCapability(currentUser?.capabilities?.spreedCapability, SpreedFeatures.FEDERATION_V1)) { + if (hasSpreedFeatureCapability(currentUser.capabilities?.spreedCapability, SpreedFeatures.FEDERATION_V1)) { conversationsListViewModel.getFederationInvitations() } } @@ -780,6 +815,7 @@ class ConversationsListActivity : BaseActivity() { private fun showNewConversationsScreen() { val intent = Intent(context, ContactsActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(intent) } @@ -792,7 +828,7 @@ class ConversationsListActivity : BaseActivity() { selectedConversation = conversation if (selectedConversation != null) { val hasChatPermission = ParticipantPermissions( - currentUser?.capabilities?.spreedCapability, + currentUser.capabilities?.spreedCapability, selectedConversation!! ) .hasChatPermission() @@ -822,11 +858,11 @@ class ConversationsListActivity : BaseActivity() { private fun shouldShowLobby(conversation: ConversationModel): Boolean { val participantPermissions = ParticipantPermissions( - currentUser?.capabilities?.spreedCapability, + currentUser.capabilities?.spreedCapability, selectedConversation!! ) return conversation.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY && - !ConversationUtils.canModerate(conversation, currentUser?.capabilities?.spreedCapability) && + !ConversationUtils.canModerate(conversation, currentUser.capabilities?.spreedCapability) && !participantPermissions.canIgnoreLobby() } @@ -1037,7 +1073,7 @@ class ConversationsListActivity : BaseActivity() { val callsChannelNotEnabled = !NotificationUtils.isCallsNotificationChannelEnabled(this) val serverNotificationAppInstalled = - currentUser?.capabilities?.notificationsCapability?.features?.isNotEmpty() == true + currentUser.capabilities?.notificationsCapability?.features?.isNotEmpty() == true val settingsOfUserAreWrong = notificationPermissionNotGranted || batteryOptimizationNotIgnored || @@ -1058,12 +1094,13 @@ class ConversationsListActivity : BaseActivity() { private fun getIntentForCall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean): Intent? { selectedConversation?.let { - val pp = ParticipantPermissions(currentUser?.capabilities?.spreedCapability, it) + val pp = ParticipantPermissions(currentUser.capabilities?.spreedCapability, it) val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, it.token) + bundle.putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, "") - bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, currentUser?.baseUrl!!) + bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, currentUser.baseUrl!!) bundle.putString(KEY_CONVERSATION_NAME, it.displayName) bundle.putInt(KEY_RECORDING_STATE, it.callRecording) bundle.putBoolean(KEY_IS_MODERATOR, ConversationUtils.isParticipantOwnerOrModerator(it)) @@ -1099,30 +1136,26 @@ class ConversationsListActivity : BaseActivity() { private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { selectedConversation?.let { val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, selectedConversation!!.token) bundle.putString(KEY_SHARED_TEXT, textToPaste) if (selectedMessageId != null) { bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId) selectedMessageId = null } - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent(context, currentUser.id!!, it.token, bundle) - if (currentUser != null) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(currentUser?.capabilities?.spreedCapability)) { - showSnackbar(context.getString(R.string.nc_call_e2ee_not_supported)) - return@let - } - val pp = ParticipantPermissions(currentUser?.capabilities?.spreedCapability, it) - if (!pp.canStartCall() && selectedConversation?.hasCall == false) { - Log.e(TAG, "Error starting call from conversations list: call is forbidden") - } else { - ApplicationWideCurrentRoomHolder.getInstance().isDialing = true - val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) - if (callIntent != null) { - startActivities(arrayOf(chatIntent, callIntent)) - } + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(currentUser.capabilities?.spreedCapability)) { + showSnackbar(context.getString(R.string.nc_call_e2ee_not_supported)) + return@let + } + val pp = ParticipantPermissions(currentUser.capabilities?.spreedCapability, it) + if (!pp.canStartCall() && selectedConversation?.hasCall == false) { + Log.e(TAG, "Error starting call from conversations list: call is forbidden") + } else { + ApplicationWideCurrentRoomHolder.getInstance().isDialing = true + val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) + if (callIntent != null) { + startActivities(arrayOf(chatIntent, callIntent)) } } } @@ -1130,14 +1163,13 @@ class ConversationsListActivity : BaseActivity() { private fun openConversation(textToPaste: String? = "") { if (CallActivity.active && - selectedConversation!!.token != ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken + !ApplicationWideCurrentRoomHolder.getInstance().isCurrentRoom(selectedConversation!!.token, currentUser.id) ) { showSnackbar(context.getString(R.string.restrict_join_other_room_while_call)) return } val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, selectedConversation!!.token) bundle.putString(KEY_SHARED_TEXT, textToPaste) if (!filesToShare.isNullOrEmpty()) { bundle.putStringArrayList(BundleKeys.KEY_SHARED_FILE_PATHS, filesToShare) @@ -1147,8 +1179,7 @@ class ConversationsListActivity : BaseActivity() { selectedMessageId = null } - val intent = Intent(context, ChatActivity::class.java) - intent.putExtras(bundle) + val intent = ChatActivity.createIntent(context, currentUser.id!!, selectedConversation!!.token, bundle) startActivity(intent) clearIntentAction() @@ -1156,7 +1187,7 @@ class ConversationsListActivity : BaseActivity() { @Subscribe(sticky = true, threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { - if (currentUser != null && eventStatus.userId == currentUser!!.id) { + if (eventStatus.userId == currentUser.id) { when (eventStatus.eventType) { EventStatus.EventType.CONVERSATION_UPDATE -> if (eventStatus.isAllGood && !isRefreshingState.value) { fetchRooms() @@ -1192,18 +1223,17 @@ class ConversationsListActivity : BaseActivity() { } private fun shareConversationLink(conversation: ConversationModel) { - val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(currentUser!!) + val canGeneratePrettyURL = CapabilitiesUtil.canGeneratePrettyURL(currentUser) ShareUtils.shareConversationLink( this, - currentUser?.baseUrl, + currentUser.baseUrl, conversation.token, canGeneratePrettyURL ) } private fun addConversationToHomeScreen(conversation: ConversationModel) { - val user = currentUser ?: return - val success = ShortcutManagerHelper.requestPinShortcut(this, conversation, user) + val success = ShortcutManagerHelper.requestPinShortcut(this, conversation, currentUser) if (success) { showSnackbar(resources.getString(R.string.nc_shortcut_created)) } else { @@ -1235,7 +1265,7 @@ class ConversationsListActivity : BaseActivity() { private fun renameConversation(conversation: ConversationModel) { if (!TextUtils.isEmpty(conversation.token)) { RenameConversationDialogFragment - .newInstance(conversation.token!!, conversation.displayName!!) + .newInstance(currentUser, conversation.token!!, conversation.displayName!!) .show(supportFragmentManager, RenameConversationDialogFragment::class.simpleName) } } @@ -1267,7 +1297,7 @@ class ConversationsListActivity : BaseActivity() { val token = conversation.token ?: return val data = Data.Builder() .putString(KEY_ROOM_TOKEN, token) - .putLong(KEY_INTERNAL_USER_ID, currentUser?.id!!) + .putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) .build() val worker = OneTimeWorkRequest.Builder(LeaveConversationWorker::class.java) .setInputData(data) @@ -1277,7 +1307,7 @@ class ConversationsListActivity : BaseActivity() { WorkManager.getInstance(this).getWorkInfoByIdLiveData(worker.id).observeForever { workInfo -> when (workInfo?.state) { WorkInfo.State.SUCCEEDED -> { - currentUser?.id?.let { userId -> + currentUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( this, token, @@ -1360,8 +1390,9 @@ class ConversationsListActivity : BaseActivity() { .setNegativeButton(R.string.nc_settings_reauthorize) { _, _ -> val intent = Intent(context, BrowserLoginActivity::class.java) val bundle = Bundle() - bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl!!) + bundle.putString(BundleKeys.KEY_BASE_URL, currentUser.baseUrl!!) bundle.putBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, true) + bundle.putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) intent.putExtras(bundle) startActivity(intent) } @@ -1376,7 +1407,7 @@ class ConversationsListActivity : BaseActivity() { private fun deleteUserAndRestartApp() { lifecycleScope.launch { - userManager.scheduleUserForDeletionWithId(currentUser!!.id!!) + userManager.scheduleUserForDeletionWithId(currentUser.id!!) val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java) .setExpeditedIfSupported() .build() @@ -1389,7 +1420,7 @@ class ConversationsListActivity : BaseActivity() { WorkInfo.State.SUCCEEDED -> { val text = String.format( context.resources.getString(R.string.nc_deleted_user), - currentUser!!.displayName + currentUser.displayName ) Toast.makeText( context, @@ -1400,7 +1431,7 @@ class ConversationsListActivity : BaseActivity() { } WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - logger.e(TAG, "something went wrong when deleting user with id " + currentUser!!.userId) + logger.e(TAG, "something went wrong when deleting user with id " + currentUser.userId) Toast.makeText( context, @@ -1514,7 +1545,7 @@ class ConversationsListActivity : BaseActivity() { val data = Data.Builder() data.putLong( KEY_INTERNAL_USER_ID, - currentUser?.id!! + currentUser.id!! ) data.putString(KEY_ROOM_TOKEN, conversation.token) @@ -1530,7 +1561,7 @@ class ConversationsListActivity : BaseActivity() { if (workInfo != null) { when (workInfo.state) { WorkInfo.State.SUCCEEDED -> { - currentUser?.id?.let { userId -> + currentUser.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( context, conversation.token, @@ -1561,7 +1592,7 @@ class ConversationsListActivity : BaseActivity() { fun openFollowedThreadsOverview() { val threadsUrl = ApiUtils.getUrlForSubscribedThreads( version = 1, - baseUrl = currentUser!!.baseUrl + baseUrl = currentUser.baseUrl ) val bundle = Bundle() @@ -1569,11 +1600,27 @@ class ConversationsListActivity : BaseActivity() { bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl) val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java) threadsOverviewIntent.putExtras(bundle) + threadsOverviewIntent.putExtra(KEY_INTERNAL_USER_ID, currentUser.id) startActivity(threadsOverviewIntent) } companion object { private val TAG = ConversationsListActivity::class.java.simpleName + + /** + * Creates an intent that shows the conversation list of the account with the internal id [userId]. + * A conversation list already in the task is recreated for that account and all screens above it are + * closed, so no screen of a previously shown account stays in the back stack. + * + * FLAG_ACTIVITY_NEW_TASK must not be used here: activities have an empty task affinity, so it would start + * a second task instead of reusing the current one. + */ + fun createAccountSwitchIntent(context: Context, userId: Long): Intent = + Intent(context, ConversationsListActivity::class.java).apply { + putExtra(KEY_INTERNAL_USER_ID, userId) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + const val BOTTOM_SHEET_DELAY: Long = 2500 const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 const val HTTP_UNAUTHORIZED = 401 diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt index 72d9efe613..ee32eb0c7d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt @@ -16,11 +16,12 @@ import kotlinx.coroutines.flow.Flow interface OfflineConversationsRepository { /** - * Live stream of the observed account's conversations, for use in the conversation list. - * Backed by the local database: it re-emits whenever conversation rows change (room list - * sync, background catch-up, optimistic updates), with unchanged lists deduplicated. + * Live stream of the conversations of the account with the internal id [accountId], for use in + * the conversation list. Backed by the local database: it re-emits whenever conversation rows + * change (room list sync, background catch-up, optimistic updates), with unchanged lists + * deduplicated. */ - val roomListFlow: Flow> + fun roomListFlow(accountId: Long): Flow> /** * Emits when [getRooms] fails to sync with the server (e.g. a dropped/reset connection on a @@ -38,9 +39,8 @@ interface OfflineConversationsRepository { val conversationFlow: Flow /** - * Selects the account observed by [roomListFlow] and synchronizes its conversations with - * the server (when online). The synced changes surface through [roomListFlow], which - * observes the database. + * Synchronizes the conversations of [user] with the server (when online). The synced changes + * surface through [roomListFlow], which observes the database. */ @Deprecated("use observeConversation") fun getRooms(user: User): Job diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index 91738cb1a9..4f85af9f25 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -32,15 +32,11 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -59,24 +55,15 @@ class OfflineFirstConversationsRepository @Inject constructor( private val context: Context, private val logger: Logger ) : OfflineConversationsRepository { - private val observedAccountId = MutableStateFlow(null) - /** * The conversation list as a live view of the local database — the single source of truth. * Every write to the conversations table (room list sync, background message catch-up, - * optimistic read state, drafts) reaches collectors reactively; [getRooms] only selects the - * account to observe and triggers the background sync, which stays in place as the authority - * and self-healing safeguard. + * optimistic read state, drafts) reaches collectors reactively; [getRooms] only triggers the + * background sync, which stays in place as the authority and self-healing safeguard. */ - @OptIn(ExperimentalCoroutinesApi::class) - override val roomListFlow: Flow> = - observedAccountId - .filterNotNull() - .distinctUntilChanged() - .flatMapLatest { accountId -> - dao.getConversationsForUser(accountId) - .map { entities -> entities.map(ConversationEntity::toDomainModel) } - } + override fun roomListFlow(accountId: Long): Flow> = + dao.getConversationsForUser(accountId) + .map { entities -> entities.map(ConversationEntity::toDomainModel) } .distinctUntilChanged() override val conversationFlow: Flow @@ -109,8 +96,6 @@ class OfflineFirstConversationsRepository @Inject constructor( override fun getRooms(user: User): Job = scope.launch { - observedAccountId.value = user.id!! - if (networkMonitor.isOnline.value) { getRoomsFromServer(user) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index ecb3ab613d..2b44f18b98 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -42,8 +42,10 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.withRetry +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -66,13 +68,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -import javax.inject.Inject -@Suppress("LongParameterList", "TooManyFunctions") -class ConversationsListViewModel @Inject constructor( +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") +class ConversationsListViewModel @AssistedInject constructor( private val repository: OfflineConversationsRepository, private val threadsRepository: ThreadsRepository, - private val currentUserProvider: CurrentUserProviderOld, private val openConversationsRepository: OpenConversationsRepository, private val contactsRepository: ContactsRepository, private val unifiedSearchRepository: UnifiedSearchRepository, @@ -81,12 +81,11 @@ class ConversationsListViewModel @Inject constructor( var userManager: UserManager, private val conversationsRepository: ConversationsRepository, private val conversationListUpdater: ConversationListUpdater, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { - private val _currentUser = currentUserProvider.currentUser.blockingGet() - val currentUser: User = _currentUser - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val searchHelper = MessageSearchHelper(unifiedSearchRepository, currentUser) @@ -145,7 +144,7 @@ class ConversationsListViewModel @Inject constructor( val getRoomsViewState: LiveData get() = _getRoomsViewState - val getRoomsFlow = repository.roomListFlow + val getRoomsFlow = repository.roomListFlow(currentUser.id!!) .onEach { list -> _getRoomsViewState.value = GetRoomsSuccessState(list.isNotEmpty()) }.catch { @@ -167,7 +166,7 @@ class ConversationsListViewModel @Inject constructor( val isLoadingRooms: StateFlow = _isLoadingRooms.asStateFlow() val getRoomsStateFlow = repository - .roomListFlow + .roomListFlow(currentUser.id!!) .catch { throwable -> Log.e(TAG, "Error observing the conversation list", throwable) _getRoomsViewState.value = GetRoomsErrorState(throwable) @@ -576,7 +575,7 @@ class ConversationsListViewModel @Inject constructor( fun checkIfThreadsExist() { val limitForFollowedThreadsExistenceCheck = 1 - val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet()) + val accountId = UserIdUtils.getIdForUser(currentUser) fun isLastCheckTooOld(lastCheckDate: Long): Boolean { val currentTimeMillis = System.currentTimeMillis() @@ -944,8 +943,6 @@ class ConversationsListViewModel @Inject constructor( } override fun onNext(invitationsModel: InvitationsModel) { - val currentUser = currentUserProvider.currentUser.blockingGet() - if (invitationsModel.user.userId?.equals(currentUser.userId) == true && invitationsModel.user.baseUrl?.equals(currentUser.baseUrl) == true ) { @@ -966,6 +963,11 @@ class ConversationsListViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): ConversationsListViewModel + } + companion object { private val TAG = ConversationsListViewModel::class.java.simpleName const val FOLLOWED_THREADS_EXIST_LAST_CHECK = "FOLLOWED_THREADS_EXIST_LAST_CHECK" diff --git a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt index 7077f74f5f..c5dbff4ce1 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt @@ -22,7 +22,9 @@ import com.nextcloud.talk.models.json.tags.ConversationTagErrorOverall import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -30,18 +32,16 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import retrofit2.HttpException -import javax.inject.Inject @Suppress("TooManyFunctions") -class ConversationTagsViewModel @Inject constructor( +class ConversationTagsViewModel @AssistedInject constructor( private val conversationTagsRepository: ConversationTagsRepository, private val repository: OfflineConversationsRepository, - private val currentUserProvider: CurrentUserProviderOld, private val conversationListUpdater: ConversationListUpdater, - private val logger: Logger + private val logger: Logger, + @Assisted private val currentUser: User ) : ViewModel() { - private val currentUser: User = currentUserProvider.currentUser.blockingGet() private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val _conversationTagsFlow = MutableStateFlow>(emptyList()) @@ -218,6 +218,11 @@ class ConversationTagsViewModel @Inject constructor( private fun List?.toDisplayTags(): List = this?.filter { it.type != ConversationTagDto.TYPE_OTHER }?.sortedBy { it.sortOrder } ?: emptyList() + @AssistedFactory + interface Factory { + fun build(user: User): ConversationTagsViewModel + } + companion object { private val TAG = ConversationTagsViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java b/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java index c98fd2e82f..c4c0e36255 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java @@ -17,6 +17,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.users.UserManager; import com.nextcloud.talk.utils.ApiUtils; +import com.nextcloud.talk.utils.CredentialsCookieInterceptor; import com.nextcloud.talk.utils.RemoteWipeInterceptor; import com.nextcloud.talk.utils.LoggingUtils; import com.nextcloud.talk.utils.preferences.AppPreferences; @@ -223,6 +224,8 @@ OkHttpClient provideHttpClient(Proxy proxy, AppPreferences appPreferences, httpClient.addInterceptor(new HeadersInterceptor()); httpClient.addInterceptor(new RemoteWipeInterceptor(userManager, context, sslSocketFactoryCompat, trustManager)); httpClient.addInterceptor(loggingHttpInterceptor); + // Session cookies of one account must not authenticate the requests of another one on the same server. + httpClient.addNetworkInterceptor(new CredentialsCookieInterceptor()); return httpClient.build(); } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 421eb093b1..6e0c542db7 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -7,37 +7,25 @@ */ package com.nextcloud.talk.dagger.modules +import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewViewModel -import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel -import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel -import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel -import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel -import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel -import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel -import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel -import com.nextcloud.talk.diagnosis.DiagnosisViewModel import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel -import com.nextcloud.talk.messagesearch.MessageSearchViewModel -import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel -import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToViewModel import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel -import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel -import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel -import com.nextcloud.talk.translate.viewmodels.TranslateViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel import dagger.Binds @@ -62,6 +50,13 @@ class ViewModelFactoryWithParams(private val modelClass: Class } } +/** + * Lazily creates a view model with [create] on first access, scoped to this activity like [viewModels]. For view + * models that take arguments, e.g. via assisted injection. Everything [create] uses must be set before that access. + */ +inline fun ComponentActivity.assistedViewModels(noinline create: () -> VM): Lazy = + viewModels { ViewModelFactoryWithParams(VM::class.java, create) } + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) @Retention(AnnotationRetention.RUNTIME) @MapKey @@ -84,11 +79,6 @@ abstract class ViewModelModule { @ViewModelKey(MediaViewerViewModel::class) abstract fun mediaViewerViewModel(viewModel: MediaViewerViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(MessageSearchViewModel::class) - abstract fun messageSearchViewModel(viewModel: MessageSearchViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(PollMainViewModel::class) @@ -109,11 +99,6 @@ abstract class ViewModelModule { @ViewModelKey(PollCreateViewModel::class) abstract fun pollCreateViewModel(viewModel: PollCreateViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(RemoteFileBrowserItemsViewModel::class) - abstract fun remoteFileBrowserItemsViewModel(viewModel: RemoteFileBrowserItemsViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(CallRecordingViewModel::class) @@ -129,31 +114,6 @@ abstract class ViewModelModule { @ViewModelKey(RaiseHandViewModel::class) abstract fun raiseHandViewModel(viewModel: RaiseHandViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(TranslateViewModel::class) - abstract fun translateViewModel(viewModel: TranslateViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(OpenConversationsViewModel::class) - abstract fun openConversationsViewModel(viewModel: OpenConversationsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ConversationsListViewModel::class) - abstract fun conversationsListViewModel(viewModel: ConversationsListViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ConversationTagsViewModel::class) - abstract fun conversationTagsViewModel(viewModel: ConversationTagsViewModel): ViewModel - - // @Binds - // @IntoMap - // @ViewModelKey(ChatViewModel::class) - // abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(ConversationInfoViewModel::class) @@ -169,31 +129,11 @@ abstract class ViewModelModule { @ViewModelKey(InvitationsViewModel::class) abstract fun invitationsViewModel(viewModel: InvitationsViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ContactsViewModel::class) - abstract fun contactsViewModel(viewModel: ContactsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ConversationCreationViewModel::class) - abstract fun conversationCreationViewModel(viewModel: ConversationCreationViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(DiagnosisViewModel::class) - abstract fun diagnosisViewModel(viewModel: DiagnosisViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(LogsViewModel::class) abstract fun logsViewModel(viewModel: LogsViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ThreadsOverviewViewModel::class) - abstract fun threadsOverviewViewModel(viewModel: ThreadsOverviewViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(BrowserLoginActivityViewModel::class) @@ -209,36 +149,8 @@ abstract class ViewModelModule { @ViewModelKey(CallViewModel::class) abstract fun callViewModel(viewModel: CallViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(StatusViewModel::class) - abstract fun statusRepositoryViewModel(viewModel: StatusViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(StatusMessageViewModel::class) - abstract fun statusMessageViewModel(viewModel: StatusMessageViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ScheduledMessagesViewModel::class) - abstract fun scheduledMessagesViewModel(viewModel: ScheduledMessagesViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(ChooseAccountShareToViewModel::class) - abstract fun chooseAccountShareToViewModel(viewModel: ChooseAccountShareToViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(FileAttachmentPreviewViewModel::class) internal abstract fun fileAttachmentPreviewViewModel(viewModel: FileAttachmentPreviewViewModel): ViewModel } - -// @Module -// interface ChatViewModelAssistedModule { -// @Binds -// fun bindChatViewModelFactory( -// factory: ChatViewModel.Factory -// ): ChatViewModel.Factory -// } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt index 27f7019a1c..cc5c5e5204 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt @@ -13,7 +13,12 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow @@ -52,6 +57,18 @@ interface UsersDao { @Query("SELECT * FROM User where id = :id") suspend fun getUserWithId(id: Long): UserEntity? + @Query("SELECT * FROM User where id = :id") + fun getUserWithIdFlow(id: Long): Flow + + // If several users are marked as active, keeps only the one with the highest id, matching getActiveUser(). + // A single statement, so it cannot interleave with setUserAsActiveWithId(). SQLite evaluates the uncorrelated + // subqueries once, before any row is changed. + @Query( + "UPDATE User SET current = CASE WHEN id = (SELECT MAX(id) FROM User WHERE current = 1) THEN 1 ELSE 0 END " + + "WHERE (SELECT COUNT(*) FROM User WHERE current = 1) > 1" + ) + suspend fun repairMultipleActiveUsers(): Int + @Query("SELECT * FROM User where id = :id AND scheduledForDeletion != 1") suspend fun getUserWithIdNotScheduledForDeletion(id: Long): UserEntity? @@ -78,6 +95,25 @@ interface UsersDao { @Query("Update User SET pushConfigurationState = :state WHERE id == :id") suspend fun updatePushState(id: Long, state: PushConfigurationState): Int + // The following updates only write the columns of the given partial entity. Writing back a whole user row that + // was read earlier (e.g. before a network request) would reset columns that changed in the meantime, like + // "current". + + @Update(entity = UserEntity::class) + suspend fun updateCapabilities(update: UserCapabilitiesUpdate): Int + + @Update(entity = UserEntity::class) + suspend fun updateExternalSignalingServer(update: UserExternalSignalingServerUpdate): Int + + @Update(entity = UserEntity::class) + suspend fun updateDisplayName(update: UserDisplayNameUpdate): Int + + @Update(entity = UserEntity::class) + suspend fun updateClientCertificate(update: UserClientCertificateUpdate): Int + + @Update(entity = UserEntity::class) + suspend fun updateCredentials(update: UserCredentialsUpdate): Int + companion object { const val TAG = "UsersDao" } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt index ce15a57077..bf79cf82a9 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepository.kt @@ -8,6 +8,9 @@ package com.nextcloud.talk.data.user import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow @@ -17,6 +20,8 @@ interface UsersRepository { fun getActiveUserFlow(): Flow suspend fun getUsers(): List suspend fun getUserWithId(id: Long): User? + fun getUserWithIdFlow(id: Long): Flow + suspend fun repairMultipleActiveUsers(): Int suspend fun getUserWithIdNotScheduledForDeletion(id: Long): User? suspend fun getUserWithUserId(userId: String): User? suspend fun getUsersScheduledForDeletion(): List @@ -27,4 +32,9 @@ interface UsersRepository { suspend fun setUserAsActiveWithId(id: Long): Boolean suspend fun deleteUser(user: User): Int suspend fun updatePushState(id: Long, state: PushConfigurationState): Int + suspend fun updateCapabilities(id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto?): Int + suspend fun updateExternalSignalingServer(id: Long, externalSignalingServer: ExternalSignalingServer?): Int + suspend fun updateDisplayName(id: Long, displayName: String?): Int + suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int + suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt index 5a53c3bbf8..e995d3726d 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/UsersRepositoryImpl.kt @@ -9,6 +9,14 @@ package com.nextcloud.talk.data.user import android.util.Log import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -16,11 +24,7 @@ import kotlinx.coroutines.flow.map @Suppress("TooManyFunctions") class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { - override suspend fun getActiveUser(): User? { - val entity = usersDao.getActiveUser() ?: return null - setUserAsActiveWithId(entity.id) - return UserMapper.toModel(entity) - } + override suspend fun getActiveUser(): User? = UserMapper.toModel(usersDao.getActiveUser()) override fun getActiveUserFlow(): Flow = usersDao.getActiveUserFlow().map { @@ -31,6 +35,13 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { override suspend fun getUserWithId(id: Long): User? = UserMapper.toModel(usersDao.getUserWithId(id)) + override fun getUserWithIdFlow(id: Long): Flow = + usersDao.getUserWithIdFlow(id).map { + UserMapper.toModel(it) + } + + override suspend fun repairMultipleActiveUsers(): Int = usersDao.repairMultipleActiveUsers() + override suspend fun getUserWithIdNotScheduledForDeletion(id: Long): User? = UserMapper.toModel(usersDao.getUserWithIdNotScheduledForDeletion(id)) @@ -61,6 +72,26 @@ class UsersRepositoryImpl(private val usersDao: UsersDao) : UsersRepository { override suspend fun updatePushState(id: Long, state: PushConfigurationState): Int = usersDao.updatePushState(id, state) + override suspend fun updateCapabilities( + id: Long, + capabilities: CapabilitiesDto?, + serverVersion: ServerVersionDto? + ): Int = usersDao.updateCapabilities(UserCapabilitiesUpdate(id, capabilities, serverVersion)) + + override suspend fun updateExternalSignalingServer( + id: Long, + externalSignalingServer: ExternalSignalingServer? + ): Int = usersDao.updateExternalSignalingServer(UserExternalSignalingServerUpdate(id, externalSignalingServer)) + + override suspend fun updateDisplayName(id: Long, displayName: String?): Int = + usersDao.updateDisplayName(UserDisplayNameUpdate(id, displayName)) + + override suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = + usersDao.updateClientCertificate(UserClientCertificateUpdate(id, clientCertificate)) + + override suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = + usersDao.updateCredentials(UserCredentialsUpdate(id, token, clientCertificate)) + companion object { private val TAG = UsersRepositoryImpl::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt b/app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt new file mode 100644 index 0000000000..336f94c1bf --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/data/user/model/UserPartialUpdates.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user.model + +import androidx.room.ColumnInfo +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto + +// Partial entities of UserEntity for Room's @Update(entity = UserEntity::class): each writes only its own columns +// of the user with the given id. + +data class UserCapabilitiesUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "capabilities") val capabilities: CapabilitiesDto?, + @ColumnInfo(name = "serverVersion") val serverVersion: ServerVersionDto? +) + +data class UserExternalSignalingServerUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "externalSignalingServer") val externalSignalingServer: ExternalSignalingServer? +) + +data class UserDisplayNameUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "displayName") val displayName: String? +) + +data class UserClientCertificateUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "clientCertificate") val clientCertificate: String? +) + +data class UserCredentialsUpdate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "token") val token: String?, + @ColumnInfo(name = "clientCertificate") val clientCertificate: String? +) diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index ebd4d0c0ed..920583a214 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.nextcloud.talk.R @@ -39,10 +38,11 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.errorhandling.saveLogsAsZip import com.nextcloud.talk.logger.LogsRepository -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil @@ -57,14 +57,11 @@ class DiagnosisActivity : BaseActivity() { lateinit var arbitraryStorageManager: ArbitraryStorageManager @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: DiagnosisViewModel.Factory @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager - @Inject lateinit var networkMonitor: NetworkMonitor @@ -74,6 +71,9 @@ class DiagnosisActivity : BaseActivity() { @Inject lateinit var logsRepository: LogsRepository + private lateinit var user: User + + private val diagnosisViewModel: DiagnosisViewModel by assistedViewModels { viewModelFactory.build(user) } private val diagnosisData = mutableListOf() private val diagnosisDataState = mutableStateOf(emptyList()) @@ -92,10 +92,7 @@ class DiagnosisActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val diagnosisViewModel = ViewModelProvider( - this, - viewModelFactory - )[DiagnosisViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return val colorScheme = viewThemeUtils.getColorScheme(this) val isGooglePlayServicesAvailable = ClosedInterfaceImpl().isGooglePlayServicesAvailable @@ -128,7 +125,8 @@ class DiagnosisActivity : BaseActivity() { userManager, appPreferences, arbitraryStorageManager, - logsRepository + logsRepository, + diagnosisViewModel.currentUser ) ) diagnosisDataState.value = diagnosisData.toList() diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt index 081ea411f8..c161d9b877 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisElement.kt @@ -15,6 +15,7 @@ import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.LogsRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -56,13 +57,14 @@ private const val PUSH_TOKEN_PREFIX_END: Int = 5 private val TAG = DiagnosisActivity::class.java.simpleName -@Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod") +@Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod", "LongParameterList") fun buildDiagnosisElements( context: Context, userManager: UserManager, appPreferences: AppPreferences, arbitraryStorageManager: ArbitraryStorageManager? = null, - logsRepository: LogsRepository + logsRepository: LogsRepository, + accountUser: User? = null ): List { val data = mutableListOf() @@ -201,7 +203,7 @@ fun buildDiagnosisElements( // Account try { - val user = runBlocking { userManager.getCurrentUser() } ?: return data + val user = accountUser ?: runBlocking { userManager.getDefaultUser() } ?: return data addHeadline(context.getString(R.string.nc_diagnosis_account_category_title)) addEntry(context.getString(R.string.nc_diagnosis_account_server), user.baseUrl ?: "") addEntry(context.getString(R.string.nc_diagnosis_account_user_name), user.displayName ?: "") diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt index cb5d7564cf..1bebc8cca9 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisViewModel.kt @@ -14,21 +14,20 @@ import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.logger.Logger -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("TooGenericExceptionCaught") -class DiagnosisViewModel @Inject constructor( +class DiagnosisViewModel @AssistedInject constructor( private val ncApiCoroutines: NcApiCoroutines, - private val currentUserProvider: CurrentUserProviderOld, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { - private val _currentUser = currentUserProvider.currentUser.blockingGet() - val currentUser: User = _currentUser - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val _notificationViewState = MutableStateFlow(NotificationUiState.None) val notificationViewState: StateFlow = _notificationViewState @@ -46,7 +45,7 @@ class DiagnosisViewModel @Inject constructor( val response = ncApiCoroutines.testPushNotifications( credentials, ApiUtils - .getUrlForTestPushNotifications(_currentUser.baseUrl ?: "") + .getUrlForTestPushNotifications(currentUser.baseUrl ?: "") ) val notificationMessage = response.ocs?.data?.message _notificationViewState.value = NotificationUiState.Success(notificationMessage) @@ -64,6 +63,11 @@ class DiagnosisViewModel @Inject constructor( _showDialog.value = false } + @AssistedFactory + interface Factory { + fun build(user: User): DiagnosisViewModel + } + companion object { private val TAG = DiagnosisViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt index 26bc6fd8e4..88929ce086 100644 --- a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt @@ -49,6 +49,7 @@ import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.UriUtils import kotlin.math.min private const val ROUNDING_PIXEL = 16f @@ -230,7 +231,7 @@ fun ImageView.loadThumbnail(url: String, user: User): io.reactivex.disposables.D layers[1] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground) requestBuilder.placeholder(LayerDrawable(layers)) - if (url.startsWith(user.baseUrl!!) && + if (UriUtils.isOnServer(url, user.baseUrl!!) && (url.contains("index.php/core/preview") || url.contains("/avatar/") || url.contains("remote.php/dav/")) ) { requestBuilder.addHeader( @@ -256,7 +257,7 @@ fun ImageView.loadImage(url: String, user: User, placeholder: Drawable? = null): .error(finalPlaceholder) .transformations(RoundedCornersTransformation(ROUNDING_PIXEL, ROUNDING_PIXEL, ROUNDING_PIXEL, ROUNDING_PIXEL)) - if (url.startsWith(user.baseUrl!!) && + if (UriUtils.isOnServer(url, user.baseUrl!!) && (url.contains("index.php/core/preview") || url.contains("/avatar/") || url.contains("remote.php/dav/")) ) { requestBuilder.addHeader( diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt index 8a4d9e432e..7f3e118e04 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt @@ -13,7 +13,6 @@ import android.content.Intent import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.MaterialTheme import androidx.core.content.FileProvider import androidx.core.net.toUri @@ -21,36 +20,33 @@ import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R +import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.TEXT_PREFIX_GENERIC -import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID import java.io.File -import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class FullScreenTextViewerActivity : AppCompatActivity() { +class FullScreenTextViewerActivity : BaseActivity() { - @Inject - lateinit var viewThemeUtils: ViewThemeUtils private lateinit var textFile: File override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val user = setUpBoundUserOrFinish() ?: return val fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isMarkdown = intent.getBooleanExtra("IS_MARKDOWN", false) val fileId = intent.getStringExtra("FILE_ID").orEmpty() val link = intent.getStringExtra("LINK") - val username = intent.getStringExtra("USERNAME").orEmpty() - val baseUrl = intent.getStringExtra("BASE_URL").orEmpty() + val username = user.username.orEmpty() + val baseUrl = user.baseUrl.orEmpty() textFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { Log.e(TAG, "Invalid text filename: $fileName") finish() @@ -58,8 +54,6 @@ class FullScreenTextViewerActivity : AppCompatActivity() { } val text = readFile(textFile) - adjustUIForAPILevel35() - setContent { val colorScheme = viewThemeUtils.getColorScheme(this) MaterialTheme(colorScheme = colorScheme) { diff --git a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt index ea954a6ed9..e7766da05c 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/InvitationsActivity.kt @@ -49,6 +49,7 @@ class InvitationsActivity : BaseActivity() { private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val intent = Intent(this@InvitationsActivity, ConversationsListActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser.id) startActivity(intent) } } @@ -56,10 +57,10 @@ class InvitationsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + currentUser = setUpBoundUserOrFinish() ?: return invitationsViewModel = ViewModelProvider(this, viewModelFactory)[InvitationsViewModel::class.java] - currentUser = currentUserProviderOld.currentUser.blockingGet() invitationsViewModel.fetchInvitations(currentUser) binding = ActivityInvitationsBinding.inflate(layoutInflater) @@ -136,12 +137,11 @@ class InvitationsActivity : BaseActivity() { is InvitationsViewModel.InvitationActionSuccessState -> { if (state.action == ActionEnum.ACCEPT) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, state.invitation.localToken) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) - chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(chatIntent) + state.invitation.localToken?.let { token -> + val chatIntent = ChatActivity.createIntent(context, currentUser.id!!, token) + chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(chatIntent) + } } else { // adapter.currentList.remove(state.invitation) // adapter.notifyDataSetChanged() // leads to UnsupportedOperationException ?! diff --git a/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt b/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt index 104ea73352..56c6748664 100644 --- a/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/invitation/adapters/InvitationsAdapter.kt @@ -19,6 +19,7 @@ import com.nextcloud.talk.databinding.RvItemInvitationBinding import com.nextcloud.talk.invitation.InvitationsActivity import com.nextcloud.talk.invitation.data.Invitation import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -64,6 +65,7 @@ class InvitationsAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InvitationsViewHolder { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(parent.context, viewThemeUtils) return InvitationsViewHolder( RvItemInvitationBinding.inflate( LayoutInflater.from(parent.context), diff --git a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt index 85886ec93d..57c1b33400 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesFetcher.kt @@ -84,7 +84,7 @@ class CapabilitiesFetcher( user.serverVersion = capabilitiesOverall.ocs?.data?.serverVersion return try { - val success = userManager.updateOrCreateUser(user) > 0 + val success = userManager.updateCapabilities(user.id!!, user.capabilities, user.serverVersion) > 0 if (!success) { Log.w(TAG, "Error updating user") } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt index 70a0666ab4..30abc74e85 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/ContactAddressBookWorker.kt @@ -34,11 +34,11 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.models.json.search.ContactsByNumberOverall +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ContactUtils import com.nextcloud.talk.utils.DateConstants -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers @@ -59,7 +59,7 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar lateinit var userManager: UserManager @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + lateinit var defaultAccountProvider: DefaultAccountProvider @Inject lateinit var appPreferences: AppPreferences @@ -71,7 +71,8 @@ class ContactAddressBookWorker(val context: Context, workerParameters: WorkerPar override fun doWork(): Result { sharedApplication!!.componentApplication.inject(this) - val currentUser = currentUserProvider.currentUser.blockingGet() + // The phone book integration is not bound to an account, so it syncs for the default account. + val currentUser = defaultAccountProvider.getDefaultUserBlocking() accountName = context.getString(R.string.nc_app_product_name) accountType = BuildConfig.APPLICATION_ID diff --git a/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt index db0f9039f2..2c9f00d9cd 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt @@ -19,8 +19,9 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.preferences.AppPreferences +import kotlinx.coroutines.runBlocking import okhttp3.ResponseBody import java.io.BufferedInputStream import java.io.File @@ -41,9 +42,6 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var appPreferences: AppPreferences @@ -55,9 +53,7 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa } try { - val currentUser = currentUserProvider.currentUser.blockingGet() - val baseUrl = inputData.getString(KEY_BASE_URL) - val userId = inputData.getString(KEY_USER_ID) + val currentUser = runBlocking { userManager.getUserWithId(inputData.getLong(KEY_INTERNAL_USER_ID, 0L)) } val attachmentFolder = inputData.getString(KEY_ATTACHMENT_FOLDER) val fileId = inputData.getString(KEY_FILE_ID) val fileName = inputData.getString(KEY_FILE_NAME) @@ -65,8 +61,8 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa totalFileSize = (inputData.getLong(KEY_FILE_SIZE, -1)) checkNotNull(currentUser) - checkNotNull(baseUrl) - checkNotNull(userId) + val baseUrl = checkNotNull(currentUser.baseUrl) + val userId = checkNotNull(currentUser.userId) checkNotNull(attachmentFolder) checkNotNull(fileName) checkNotNull(remotePath) @@ -147,8 +143,6 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa companion object { const val TAG = "DownloadFileToCache" - const val KEY_BASE_URL = "KEY_BASE_URL" - const val KEY_USER_ID = "KEY_USER_ID" const val KEY_ATTACHMENT_FOLDER = "KEY_ATTACHMENT_FOLDER" const val KEY_FILE_ID = "KEY_FILE_ID" const val KEY_FILE_NAME = "KEY_FILE_NAME" diff --git a/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt index da17ac617f..ab512b8409 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/LeaveConversationWorker.kt @@ -26,11 +26,11 @@ import com.nextcloud.talk.utils.ApiUtils.getConversationApiVersion import com.nextcloud.talk.utils.ApiUtils.getCredentials import com.nextcloud.talk.utils.ApiUtils.getUrlForParticipantsSelf import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking import retrofit2.HttpException import javax.inject.Inject @@ -45,15 +45,13 @@ class LeaveConversationWorker(context: Context, workerParams: WorkerParameters) @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - private val result = SettableFuture.create() override fun startWork(): ListenableFuture { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) val conversationToken = inputData.getString(BundleKeys.KEY_ROOM_TOKEN) - val currentUser = currentUserProvider.currentUser.blockingGet() + val userId = inputData.getLong(BundleKeys.KEY_INTERNAL_USER_ID, 0L) + val currentUser = runBlocking { userManager.getUserWithId(userId) } if (currentUser != null && conversationToken != null) { val credentials = getCredentials(currentUser.username, currentUser.token) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 546f87ad86..6b2042243e 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -392,8 +392,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor null } - if (conversation != null && runBlocking { userManager.setUserAsActive(userBeingCalled!!) }) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled?.capabilities?.spreedCapability)) { + if (conversation != null && userBeingCalled != null) { + if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled.capabilities?.spreedCapability)) { showEndToEndEncryptionUnsupportedNotification(conversation) } else { prepareCallNotificationScreen(conversation) @@ -1062,6 +1062,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val dismissIntent = Intent(context, DismissRecordingAvailableReceiver::class.java) dismissIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId) + dismissIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) dismissIntent.putExtra(KEY_DISMISS_RECORDING_URL, dismissRecordingUrl) val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -1095,6 +1096,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val shareRecordingIntent = Intent(context, ShareRecordingToChatReceiver::class.java) shareRecordingIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId) + shareRecordingIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) shareRecordingIntent.putExtra(KEY_SHARE_RECORDING_TO_CHAT_URL, shareToChatUrl) shareRecordingIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java index 1f11f65b3d..fecb112a72 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/SignalingSettingsWorker.java @@ -116,7 +116,10 @@ public void onNext(SignalingSettingsOverall signalingSettingsOverall) { try { int rows = BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, - (scope, continuation) -> userManager.saveUser(user, continuation)); + (scope, continuation) -> userManager.updateExternalSignalingServer( + user.getId(), + externalSignalingServer, + continuation)); saved = rows > 0; } catch (Exception e) { saved = false; diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index 1725c70c19..985bfdaa6b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -50,7 +50,7 @@ import com.nextcloud.talk.utils.ImageCompressor import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.RemoteFileUtils import com.nextcloud.talk.utils.VideoCompressor -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable @@ -85,9 +85,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa @Inject lateinit var ncApiCoroutines: NcApiCoroutines - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var appPreferences: AppPreferences @@ -102,7 +99,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa lateinit var fileName: String - private var mNotifyManager: NotificationManager? = null + private val notificationManager by lazy { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } lateinit var roomToken: String lateinit var conversationName: String @@ -142,15 +141,21 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) private fun doUpload(): Result { return try { - currentUser = currentUserProvider.currentUser.blockingGet() + // Read first, so a failure below can still mark the placeholder message as failed. + referenceId = inputData.getString(KEY_REFERENCE_ID) + internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID) + + val userId = inputData.getLong(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(userId) } ?: run { + // E.g. the account was removed while the upload waited for a retry. + Log.e(TAG, "No user found for id $userId") + return failUpload() + } val sourceFile = inputData.getString(DEVICE_SOURCE_FILE) roomToken = inputData.getString(ROOM_TOKEN)!! conversationName = inputData.getString(CONVERSATION_NAME)!! val metaData = inputData.getString(META_DATA) - referenceId = inputData.getString(KEY_REFERENCE_ID) - internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID) - checkNotNull(currentUser) checkNotNull(sourceFile) require(sourceFile.isNotEmpty()) checkNotNull(roomToken) @@ -159,8 +164,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa fileName = FileUtils.getFileName(sourceFileUri, context) file = FileUtils.getFileFromUri(context, sourceFileUri) - initNotificationSetup() - if (inputData.getBoolean(COMPRESS_IMAGES, false)) { sourceFileUri = compressMediaIfPossible(sourceFileUri) } @@ -430,15 +433,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa super.onStopped() } - private fun initNotificationSetup() { - mNotifyManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - } - private fun showFailedToUploadNotification() { val failureTitle = getResourceString(context, R.string.nc_upload_failed_notification_title) val failureText = String.format( getResourceString(context, R.string.nc_upload_failed_notification_text), - fileName + failedFileName() ) val failureNotification = NotificationCompat.Builder( context, @@ -452,9 +451,17 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .setOngoing(false) .build() - mNotifyManager!!.notify(SystemClock.uptimeMillis().toInt(), failureNotification) + notificationManager.notify(SystemClock.uptimeMillis().toInt(), failureNotification) } + // The upload can fail before the file name is known, e.g. when its account does not exist anymore. + private fun failedFileName(): String = + if (::fileName.isInitialized) { + fileName + } else { + inputData.getString(DEVICE_SOURCE_FILE)?.toUri()?.lastPathSegment.orEmpty() + } + private fun getResourceString(context: Context, resourceId: Int): String = context.resources.getString(resourceId) companion object { @@ -527,6 +534,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa @Suppress("LongParameterList") fun upload( + userId: Long, fileUri: String, roomToken: String, conversationName: String, @@ -537,6 +545,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa allowUpdate: Boolean = false ): UUID { val data: Data = Data.Builder() + .putLong(KEY_INTERNAL_USER_ID, userId) .putString(DEVICE_SOURCE_FILE, fileUri) .putString(ROOM_TOKEN, roomToken) .putString(CONVERSATION_NAME, conversationName) diff --git a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt index b8a1e019f2..acea84e0ff 100644 --- a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt @@ -41,6 +41,7 @@ class GeocodingActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + setUpBoundUserOrFinish() ?: return roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(BundleKeys.KEY_CHAT_API_VERSION, 1) diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index 80b3aaa0b1..bda0ae596e 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -18,12 +18,14 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.getParcelableExtraProvider import com.nextcloud.talk.location.components.LocationPickerScreen +import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_GEOCODING_RESULT +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import com.nextcloud.talk.location.viewmodels.LocationPickerViewModel import javax.inject.Inject private const val MAP_CENTER_LAT_KEY = "mapCenterLat" @@ -34,6 +36,8 @@ private const val MOVE_TO_CURRENT_LOCATION_KEY = "moveToCurrentLocation" private const val GEOCODING_RESULT_KEY = "geocodingResult" +private const val TAG = "LocationPickerActivity" + @AutoInjector(NextcloudTalkApplication::class) class LocationPickerActivity : BaseActivity() { @@ -41,6 +45,7 @@ class LocationPickerActivity : BaseActivity() { lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var viewModel: LocationPickerViewModel + private lateinit var user: User private lateinit var roomToken: String private var chatApiVersion: Int = 1 @@ -66,6 +71,7 @@ class LocationPickerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + user = setUpBoundUserOrFinish() ?: return roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) @@ -84,6 +90,7 @@ class LocationPickerActivity : BaseActivity() { viewModel.initialize( LocationPickerViewModel.LocationPickerInitParams( + user = user, roomToken = roomToken, chatApiVersion = chatApiVersion, geocodingResult = geocodingResult, @@ -122,6 +129,7 @@ class LocationPickerActivity : BaseActivity() { private fun navigateToGeocoding() { val intent = Intent(this, GeocodingActivity::class.java) + intent.putExtra(KEY_INTERNAL_USER_ID, user.id) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra(KEY_CHAT_API_VERSION, chatApiVersion) geocodingLauncher.launch(intent) diff --git a/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt b/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt index 31d5a8b896..745dc9a434 100644 --- a/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModel.kt @@ -11,11 +11,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.location.GeocodingResult import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import fr.dudie.nominatim.client.TalkJsonNominatimClient import fr.dudie.nominatim.model.Address import io.reactivex.Observer @@ -35,7 +35,6 @@ import javax.inject.Inject @Suppress("TooManyFunctions") class LocationPickerViewModel @Inject constructor( private val ncApi: NcApi, - private val currentUserProviderOld: CurrentUserProviderOld, private val okHttpClient: OkHttpClient, private val logger: Logger ) : ViewModel() { @@ -61,6 +60,7 @@ class LocationPickerViewModel @Inject constructor( ) data class LocationPickerInitParams( + val user: User, val roomToken: String, val chatApiVersion: Int, val geocodingResult: GeocodingResult?, @@ -78,6 +78,7 @@ class LocationPickerViewModel @Inject constructor( private var isStateInitialized = false + private lateinit var user: User private var roomToken: String = "" private var chatApiVersion: Int = 1 @@ -88,6 +89,7 @@ class LocationPickerViewModel @Inject constructor( if (isStateInitialized) return isStateInitialized = true + user = params.user roomToken = params.roomToken chatApiVersion = params.chatApiVersion nominatimClient = TalkJsonNominatimClient(params.geocoderBaseUrl, okHttpClient, params.geocoderEmail) @@ -247,11 +249,9 @@ class LocationPickerViewModel @Inject constructor( "\"latitude\":\"$selectedLat\"," + "\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}" - val currentUser = currentUserProviderOld.currentUser.blockingGet() - ncApi.sendLocation( - ApiUtils.getCredentials(currentUser.username, currentUser.token), - ApiUtils.getUrlToSendLocation(chatApiVersion, currentUser.baseUrl!!, roomToken), + ApiUtils.getCredentials(user.username, user.token), + ApiUtils.getUrlToSendLocation(chatApiVersion, user.baseUrl!!, roomToken), "geo-location", objectId, metaData diff --git a/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt b/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt index 827a9a67e8..f97d175ffe 100644 --- a/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/logger/ui/LogsActivity.kt @@ -66,7 +66,6 @@ import com.nextcloud.talk.errorhandling.saveLogsAsZip import com.nextcloud.talk.logger.Level import com.nextcloud.talk.logger.LogEntry import com.nextcloud.talk.logger.LogsRepository -import com.nextcloud.talk.users.UserManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -79,9 +78,6 @@ class LogsActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var userManager: UserManager - @Inject lateinit var logsRepository: LogsRepository diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index d0f94f8396..13b8bb7b74 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -34,9 +34,13 @@ import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import java.io.File import javax.inject.Inject +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.ui.LocalImageAuthHeader +import androidx.compose.runtime.CompositionLocalProvider /** * Swipeable, group-aware media viewer - the entry point for every image/video tap in chat. See @@ -54,13 +58,14 @@ class MediaViewerActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val user = setUpBoundUserOrFinish() ?: return + val imageAuthHeader = ApiUtils.getCredentials(user.username, user.token) val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) val startMessageId = intent.getLongExtra(EXTRA_START_MESSAGE_ID, -1L) - val user = currentUserProviderOld.currentUser.blockingGet() - if (roomToken == null || seedItems.isNullOrEmpty() || user == null) { + if (roomToken == null || seedItems.isNullOrEmpty()) { Log.e(TAG, "Missing data to open the media viewer") finish() return @@ -81,14 +86,16 @@ class MediaViewerActivity : BaseActivity() { setContent { val colorScheme = viewThemeUtils.getColorScheme(this@MediaViewerActivity) MaterialTheme(colorScheme = colorScheme) { - MediaViewerScreen( - viewModel = viewModel, - onShare = ::shareFile, - onSave = ::showSaveDialog, - onControlsVisibilityChanged = { visible -> - if (visible) exitImmersiveMode() else enterImmersiveMode() - } - ) + CompositionLocalProvider(LocalImageAuthHeader provides imageAuthHeader) { + MediaViewerScreen( + viewModel = viewModel, + onShare = ::shareFile, + onSave = ::showSaveDialog, + onControlsVisibilityChanged = { visible -> + if (visible) exitImmersiveMode() else enterImmersiveMode() + } + ) + } } } } @@ -135,11 +142,13 @@ class MediaViewerActivity : BaseActivity() { fun newIntent( context: Context, + userId: Long, roomToken: String, seedItems: List, startMessageId: Long ): Intent = Intent(context, MediaViewerActivity::class.java).apply { + putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) putExtra(KEY_ROOM_TOKEN, roomToken) putParcelableArrayListExtra(EXTRA_SEED_ITEMS, ArrayList(seedItems)) putExtra(EXTRA_START_MESSAGE_ID, startMessageId) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 60894e910a..31218c2847 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -90,6 +90,8 @@ import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DrawableUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils +import com.nextcloud.talk.ui.LocalImageAuthHeader +import com.nextcloud.talk.ui.withAuthHeader import java.io.File import kotlinx.coroutines.launch import pl.droidsonroids.gif.GifDrawable @@ -330,7 +332,10 @@ private fun MediaPage( private fun PreviewPlaceholder(item: MediaViewerItem, onToggleControls: () -> Unit) { if (item.previewUrl != null) { AsyncImage( - model = item.previewUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(item.previewUrl) + .withAuthHeader(LocalImageAuthHeader.current) + .build(), contentDescription = item.fileName, modifier = Modifier.fillMaxSize().clickable(onClick = onToggleControls), contentScale = ContentScale.Fit @@ -512,7 +517,10 @@ private fun ThumbnailTile(item: MediaViewerItem, isSelected: Boolean, onClick: ( ) { if (item.previewUrl != null) { AsyncImage( - model = item.previewUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(item.previewUrl) + .withAuthHeader(LocalImageAuthHeader.current) + .build(), contentDescription = item.fileName, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt index 27563729fe..694c6f7e54 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -82,7 +83,8 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository fun initialize(user: User, roomToken: String, seedItems: List, startMessageId: Long) { this.user = user repositoryParameters = SharedItemsRepository.Parameters( - user.userId!!, + // The login name, as it authenticates the requests. The user id can differ from it. + user.username!!, user.token!!, user.baseUrl!!, roomToken @@ -137,8 +139,7 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository _uiState.update { it.copy(downloadingMessageIds = it.downloadingMessageIds + item.messageId) } val data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, user.baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, user.userId) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) .putString( DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, CapabilitiesUtil.getAttachmentFolder(user.capabilities!!.spreedCapability!!) diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt deleted file mode 100644 index 7b8ac75a34..0000000000 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.messagesearch - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.text.TextUtils -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.SearchView -import androidx.lifecycle.ViewModelProvider -import autodagger.AutoInjector -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.talk.R -import com.nextcloud.talk.activities.BaseActivity -import com.nextcloud.talk.adapters.items.LoadMoreResultsItem -import com.nextcloud.talk.adapters.items.MessageResultItem -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.conversationlist.ConversationsListActivity -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityMessageSearchBinding -import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.viewholders.FlexibleViewHolder -import io.reactivex.Observable -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class MessageSearchActivity : BaseActivity() { - - @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory - - private lateinit var binding: ActivityMessageSearchBinding - private lateinit var searchView: SearchView - - private lateinit var user: User - - private lateinit var viewModel: MessageSearchViewModel - - private var searchViewDisposable: Disposable? = null - private var adapter: FlexibleAdapter>? = null - - private val onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - setResult(Activity.RESULT_CANCELED) - finish() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - binding = ActivityMessageSearchBinding.inflate(layoutInflater) - setupActionBar() - setContentView(binding.root) - initSystemBars() - - viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] - user = currentUserProviderOld.currentUser.blockingGet() - val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! - viewModel.initialize(roomToken) - setupStateObserver() - - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.refresh(searchView.query?.toString()) - } - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - private fun setupActionBar() { - setSupportActionBar(binding.messageSearchToolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val conversationName = intent.getStringExtra(BundleKeys.KEY_CONVERSATION_NAME) - supportActionBar?.title = conversationName - viewThemeUtils.material.themeToolbar(binding.messageSearchToolbar) - } - - private fun setupStateObserver() { - viewModel.state.observe(this) { state -> - when (state) { - MessageSearchViewModel.InitialState -> showInitial() - MessageSearchViewModel.EmptyState -> showEmpty() - is MessageSearchViewModel.LoadedState -> showLoaded(state) - MessageSearchViewModel.LoadingState -> showLoading() - MessageSearchViewModel.ErrorState -> showError() - is MessageSearchViewModel.FinishedState -> onFinish() - } - } - } - - private fun showError() { - displayLoading(false) - Snackbar.make(binding.root, "Error while searching", Snackbar.LENGTH_SHORT).show() - } - - private fun showLoading() { - displayLoading(true) - } - - private fun displayLoading(loading: Boolean) { - binding.swipeRefreshLayout.isRefreshing = loading - } - - private fun showLoaded(state: MessageSearchViewModel.LoadedState) { - displayLoading(false) - binding.emptyContainer.emptyListView.visibility = View.GONE - binding.messageSearchRecycler.visibility = View.VISIBLE - setAdapterItems(state) - } - - private fun setAdapterItems(state: MessageSearchViewModel.LoadedState) { - val loadMoreItems = if (state.hasMore) { - listOf(LoadMoreResultsItem) - } else { - emptyList() - } - val newItems = - state.results.map { MessageResultItem(this, user, it, false, viewThemeUtils) } + loadMoreItems - - if (adapter != null) { - adapter!!.updateDataSet(newItems) - } else { - createAdapter(newItems) - } - } - - private fun createAdapter(items: List>) { - adapter = FlexibleAdapter(items) - binding.messageSearchRecycler.adapter = adapter - adapter!!.addListener(object : FlexibleAdapter.OnItemClickListener { - override fun onItemClick(view: View?, position: Int): Boolean { - val item = adapter!!.getItem(position) - when (item) { - is LoadMoreResultsItem -> { - viewModel.loadMore() - } - is MessageResultItem -> { - viewModel.selectMessage(item.messageEntry) - } - } - return false - } - }) - } - - private fun onFinish() { - val state = viewModel.state.value - if (state is MessageSearchViewModel.FinishedState) { - val resultIntent = Intent().apply { - putExtra(RESULT_KEY_MESSAGE_ID, state.selectedMessageId) - putExtra(RESULT_KEY_THREAD_ID, state.selectedThreadId) - } - setResult(Activity.RESULT_OK, resultIntent) - finish() - } - } - - private fun showInitial() { - displayLoading(false) - binding.messageSearchRecycler.visibility = View.GONE - binding.emptyContainer.emptyListViewHeadline.text = getString(R.string.message_search_begin_typing) - binding.emptyContainer.emptyListView.visibility = View.VISIBLE - } - - private fun showEmpty() { - displayLoading(false) - binding.messageSearchRecycler.visibility = View.GONE - binding.emptyContainer.emptyListViewHeadline.text = getString(R.string.message_search_begin_empty) - binding.emptyContainer.emptyListView.visibility = View.VISIBLE - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_search, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - val menuItem = menu.findItem(R.id.action_search) - searchView = menuItem.actionView as SearchView - setupSearchView() - menuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - searchView.requestFocus() - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - onBackPressedDispatcher.onBackPressed() - return false - } - }) - menuItem.expandActionView() - return true - } - - private fun setupSearchView() { - searchView.queryHint = getString(R.string.message_search_hint) - searchViewDisposable = observeSearchView(searchView) - .debounce { query -> - when { - TextUtils.isEmpty(query) -> Observable.empty() - else -> Observable.timer( - ConversationsListActivity.SEARCH_DEBOUNCE_INTERVAL_MS.toLong(), - TimeUnit.MILLISECONDS - ) - } - } - .distinctUntilChanged() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { newText -> viewModel.onQueryTextChange(newText) } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - - override fun onDestroy() { - super.onDestroy() - searchViewDisposable?.dispose() - } - - companion object { - const val RESULT_KEY_MESSAGE_ID = "MessageSearchActivity.result.message" - const val RESULT_KEY_THREAD_ID = "MessageSearchActivity.result.thread" - } -} diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt deleted file mode 100644 index a91acf734f..0000000000 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.messagesearch - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.logger.Logger -import com.nextcloud.talk.models.domain.SearchMessageEntry -import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * Install PlantUML plugin to render this state diagram - * @startuml - * hide empty description - * [*] --> InitialState - * InitialState --> LoadingState - * LoadingState --> EmptyState - * LoadingState --> LoadedState - * LoadingState --> LoadingState - * LoadedState --> LoadingState - * EmptyState --> LoadingState - * LoadingState --> ErrorState - * ErrorState --> LoadingState - * @enduml - */ -class MessageSearchViewModel @Inject constructor( - private val unifiedSearchRepository: UnifiedSearchRepository, - private val currentUserProvider: CurrentUserProviderOld, - private val logger: Logger -) : ViewModel() { - - sealed class ViewState - object InitialState : ViewState() - object LoadingState : ViewState() - object EmptyState : ViewState() - object ErrorState : ViewState() - class LoadedState(val results: List, val hasMore: Boolean) : ViewState() - class FinishedState(val selectedMessageId: String, val selectedThreadId: String?) : ViewState() - - private lateinit var messageSearchHelper: MessageSearchHelper - - private val _state: MutableLiveData = MutableLiveData(InitialState) - val state: LiveData - get() = _state - - private var currentUser: User = currentUserProvider.currentUser.blockingGet() - private var searchJob: Job? = null - - fun initialize(roomToken: String) { - messageSearchHelper = MessageSearchHelper( - unifiedSearchRepository, - currentUser, - roomToken - ) - } - - @Suppress("Detekt.TooGenericExceptionCaught") - fun onQueryTextChange(newText: String) { - if (newText.length >= MIN_CHARS_FOR_SEARCH) { - _state.value = LoadingState - searchJob?.cancel() - searchJob = viewModelScope.launch { - try { - val results = messageSearchHelper.startMessageSearch(newText) - onReceiveResults(results) - } catch (e: Exception) { - onError(e) - } - } - } - } - - fun loadMore() { - _state.value = LoadingState - searchJob?.cancel() - searchJob = viewModelScope.launch { - try { - val results = messageSearchHelper.loadMore() - results?.let { onReceiveResults(it) } - } catch (e: Exception) { - onError(e) - } - } - } - - private fun onReceiveResults(results: MessageSearchHelper.MessageSearchResults) { - if (results.messages.isEmpty()) { - _state.value = EmptyState - } else { - _state.value = LoadedState(results.messages, results.hasMore) - } - } - - private fun onError(throwable: Throwable) { - logger.e(TAG, "onError:", throwable) - _state.value = ErrorState - } - - fun refresh(query: String?) { - query?.let { onQueryTextChange(it) } - } - - fun selectMessage(messageEntry: SearchMessageEntry) { - _state.value = FinishedState(messageEntry.messageId!!, messageEntry.threadId) - } - - companion object { - private val TAG = MessageSearchViewModel::class.java.simpleName - private const val MIN_CHARS_FOR_SEARCH = 2 - } -} diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt index baa006739d..8b7dc48de3 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/ListOpenConversationsActivity.kt @@ -11,37 +11,42 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.utils.adjustUIForAPILevel35 -import com.nextcloud.talk.utils.bundle.BundleKeys import javax.inject.Inject +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.ui.LocalImageAuthHeader +import androidx.compose.runtime.CompositionLocalProvider @AutoInjector(NextcloudTalkApplication::class) class ListOpenConversationsActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: OpenConversationsViewModel.Factory - private lateinit var openConversationsViewModel: OpenConversationsViewModel + private lateinit var user: User + + private val openConversationsViewModel: OpenConversationsViewModel by assistedViewModels { + viewModelFactory.build(user) + } override fun onCreate(savedInstanceState: Bundle?) { adjustUIForAPILevel35() super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + user = setUpBoundUserOrFinish() ?: return - openConversationsViewModel = ViewModelProvider(this, viewModelFactory)[OpenConversationsViewModel::class.java] openConversationsViewModel.fetchConversations() - val user = currentUserProviderOld.currentUser.blockingGet() - setContent { val colorScheme = viewThemeUtils.getColorScheme(this) val viewState by openConversationsViewModel.viewState.collectAsStateWithLifecycle() @@ -49,29 +54,29 @@ class ListOpenConversationsActivity : BaseActivity() { MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() - OpenConversationsScreen( - viewState = viewState, - searchTerm = searchTerm, - userBaseUrl = user?.baseUrl, - listenerInput = OpenConversationsScreenListenerInput( - onSearchTermChange = { term -> - openConversationsViewModel.updateSearchTerm(term) - openConversationsViewModel.fetchConversations() - }, - onConversationClick = { conversation -> navigateToChat(conversation) }, - onBackClick = { onBackPressedDispatcher.onBackPressed() } + CompositionLocalProvider( + LocalImageAuthHeader provides ApiUtils.getCredentials(user.username, user.token) + ) { + OpenConversationsScreen( + viewState = viewState, + searchTerm = searchTerm, + userBaseUrl = user.baseUrl, + listenerInput = OpenConversationsScreenListenerInput( + onSearchTermChange = { term -> + openConversationsViewModel.updateSearchTerm(term) + openConversationsViewModel.fetchConversations() + }, + onConversationClick = { conversation -> navigateToChat(user.id!!, conversation) }, + onBackClick = { onBackPressedDispatcher.onBackPressed() } + ) ) - ) + } } } } - private fun navigateToChat(conversation: ConversationDto) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversation.token) - - val chatIntent = Intent(this, ChatActivity::class.java) - chatIntent.putExtras(bundle) + private fun navigateToChat(userId: Long, conversation: ConversationDto) { + val chatIntent = ChatActivity.createIntent(this, userId, conversation.token) chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(chatIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt b/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt index 94d0db1f70..480611f892 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/OpenConversationsScreen.kt @@ -90,6 +90,8 @@ import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewMode import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DisplayUtils import kotlinx.coroutines.flow.distinctUntilChanged +import com.nextcloud.talk.ui.withAuthHeader +import com.nextcloud.talk.ui.LocalImageAuthHeader private val TAG = ListOpenConversationsActivity::class.java.simpleName @@ -352,6 +354,7 @@ private fun OpenConversationItem( } val imageRequest = ImageRequest.Builder(context) .data(avatarUrl) + .withAuthHeader(LocalImageAuthHeader.current) .transformations(CircleCropTransformation()) .error(errorPlaceholder) .placeholder(errorPlaceholder) diff --git a/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt b/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt index a9cb741444..33ea9fe78b 100644 --- a/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/openconversations/viewmodels/OpenConversationsViewModel.kt @@ -9,18 +9,20 @@ package com.nextcloud.talk.openconversations.viewmodels import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.conversations.ConversationDto import com.nextcloud.talk.openconversations.data.OpenConversationsRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -class OpenConversationsViewModel @Inject constructor( +class OpenConversationsViewModel @AssistedInject constructor( private val repository: OpenConversationsRepository, - private val currentUserProvider: CurrentUserProvider + @Assisted private val user: User ) : ViewModel() { sealed interface ViewState @@ -42,34 +44,31 @@ class OpenConversationsViewModel @Inject constructor( _viewState.value = FetchConversationsStartState viewModelScope.launch { - currentUserProvider.getCurrentUser() - .onSuccess { - val apiVersion = ApiUtils.getConversationApiVersion( - it, - intArrayOf( - ApiUtils.API_V4, - ApiUtils.API_V3, - 1 - ) - ) - val url = ApiUtils.getUrlForOpenConversations(apiVersion, it.baseUrl!!) + val apiVersion = ApiUtils.getConversationApiVersion( + user, + intArrayOf( + ApiUtils.API_V4, + ApiUtils.API_V3, + 1 + ) + ) + val url = ApiUtils.getUrlForOpenConversations(apiVersion, user.baseUrl!!) - repository.fetchConversations( - it, - url, - _searchTerm.value - ) - .onSuccess { conversations -> - if (conversations.isEmpty()) { - _viewState.value = FetchConversationsEmptyState - } else { - _viewState.value = FetchConversationsSuccessState(conversations) - } - } - .onFailure { exception -> - Log.e(TAG, "Failed to fetch conversations", exception) - _viewState.value = FetchConversationsErrorState - } + repository.fetchConversations( + user, + url, + _searchTerm.value + ) + .onSuccess { conversations -> + if (conversations.isEmpty()) { + _viewState.value = FetchConversationsEmptyState + } else { + _viewState.value = FetchConversationsSuccessState(conversations) + } + } + .onFailure { exception -> + Log.e(TAG, "Failed to fetch conversations", exception) + _viewState.value = FetchConversationsErrorState } } } @@ -78,6 +77,11 @@ class OpenConversationsViewModel @Inject constructor( _searchTerm.value = newTerm } + @AssistedFactory + interface Factory { + fun build(user: User): OpenConversationsViewModel + } + companion object { private val TAG = OpenConversationsViewModel::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt index 3307b1c52b..24a37ca5f9 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollCreateDialogFragment.kt @@ -15,6 +15,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager @@ -23,6 +24,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogPollCreateBinding import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.polls.adapters.PollCreateOptionItem @@ -30,6 +32,7 @@ import com.nextcloud.talk.polls.adapters.PollCreateOptionsAdapter import com.nextcloud.talk.polls.adapters.PollCreateOptionsItemListener import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -54,10 +57,12 @@ class PollCreateDialogFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollCreateViewModel::class.java] val roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! - viewModel.setData(roomToken) + val user = BundleCompat.getParcelable(requireArguments(), KEY_USER, User::class.java)!! + viewModel.setData(user, roomToken) } @SuppressLint("InflateParams") @@ -187,10 +192,12 @@ class PollCreateDialogFragment : companion object { private val TAG = PollCreateDialogFragment::class.java.simpleName private const val KEY_ROOM_TOKEN = "keyRoomToken" + private const val KEY_USER = "keyUser" @JvmStatic - fun newInstance(roomTokenParam: String): PollCreateDialogFragment { + fun newInstance(user: User, roomTokenParam: String): PollCreateDialogFragment { val args = Bundle() + args.putParcelable(KEY_USER, user) args.putString(KEY_ROOM_TOKEN, roomTokenParam) val fragment = PollCreateDialogFragment() fragment.arguments = args diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt index 75c65a7395..089082b04c 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollLoadingFragment.kt @@ -16,6 +16,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogPollLoadingBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -29,6 +30,7 @@ class PollLoadingFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt index 3c0a5f6154..ab1e69ed20 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollMainDialogFragment.kt @@ -12,6 +12,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider @@ -23,7 +24,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogPollMainBinding import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -35,9 +36,6 @@ class PollMainDialogFragment : DialogFragment() { @Inject lateinit var viewThemeUtils: ViewThemeUtils - var currentUserProvider: CurrentUserProviderOld? = null - @Inject set - private lateinit var binding: DialogPollMainBinding private lateinit var viewModel: PollMainViewModel @@ -46,10 +44,11 @@ class PollMainDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollMainViewModel::class.java] - user = currentUserProvider?.currentUser?.blockingGet()!! + user = BundleCompat.getParcelable(requireArguments(), KEY_USER_ENTITY, User::class.java)!! val roomToken = arguments?.getString(KEY_ROOM_TOKEN)!! val isOwnerOrModerator = arguments?.getBoolean(KEY_OWNER_OR_MODERATOR)!! diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt index ae925cac65..fce36d3275 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollResultsFragment.kt @@ -25,6 +25,7 @@ import com.nextcloud.talk.polls.adapters.PollResultsAdapter import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -48,6 +49,7 @@ class PollResultsFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollResultsViewModel::class.java] parentViewModel = ViewModelProvider(requireParentFragment(), viewModelFactory)[PollMainViewModel::class.java] } diff --git a/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt b/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt index 73976fc58e..72bbe32d25 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/ui/PollVoteFragment.kt @@ -29,6 +29,7 @@ import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -51,6 +52,7 @@ class PollVoteFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) viewModel = ViewModelProvider(this, viewModelFactory)[PollVoteViewModel::class.java] parentViewModel = ViewModelProvider(requireParentFragment(), viewModelFactory)[PollMainViewModel::class.java] @@ -99,7 +101,7 @@ class PollVoteFragment : Fragment() { } binding.pollVoteSubmitButton.setOnClickListener { - viewModel.vote(parentViewModel.roomToken, parentViewModel.pollId) + viewModel.vote(parentViewModel.user, parentViewModel.roomToken, parentViewModel.pollId) } binding.pollVoteEditDismiss.setOnClickListener { diff --git a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt index 3fda1f2b1d..b2cb603a61 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollCreateViewModel.kt @@ -10,21 +10,18 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.polls.adapters.PollCreateOptionItem import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class PollCreateViewModel @Inject constructor( - private val repository: PollRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class PollCreateViewModel @Inject constructor(private val repository: PollRepository) : ViewModel() { private lateinit var roomToken: String @@ -33,7 +30,7 @@ class PollCreateViewModel @Inject constructor( object PollCreatedState : ViewState object PollCreationFailedState : ViewState - private val currentUser = currentUserProvider.currentUser.blockingGet() + private lateinit var currentUser: User private val _viewState: MutableLiveData = MutableLiveData( PollCreationState( @@ -68,7 +65,8 @@ class PollCreateViewModel @Inject constructor( addOption() } - fun setData(roomToken: String) { + fun setData(user: User, roomToken: String) { + this.currentUser = user this.roomToken = roomToken updateCreationState() } diff --git a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt index 3aa89919e5..04dc4dace8 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollMainViewModel.kt @@ -15,17 +15,13 @@ import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class PollMainViewModel @Inject constructor( - private val repository: PollRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class PollMainViewModel @Inject constructor(private val repository: PollRepository) : ViewModel() { @Inject lateinit var userManager: UserManager @@ -63,8 +59,8 @@ class PollMainViewModel @Inject constructor( private var disposable: Disposable? = null - private val currentUser = currentUserProvider.currentUser.blockingGet() - private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) + private val credentials: String? + get() = ApiUtils.getCredentials(user.username, user.token) fun setData(user: User, roomToken: String, isOwnerOrModerator: Boolean, pollId: String, pollTitle: String) { this.user = user @@ -93,7 +89,7 @@ class PollMainViewModel @Inject constructor( _viewState.value = LoadingState val url = ApiUtils.getUrlForPoll( - currentUser.baseUrl!!, + user.baseUrl!!, roomToken, pollId ) @@ -113,7 +109,7 @@ class PollMainViewModel @Inject constructor( _viewState.value = LoadingState val url = ApiUtils.getUrlForPoll( - currentUser.baseUrl!!, + user.baseUrl!!, roomToken, pollId ) @@ -187,7 +183,7 @@ class PollMainViewModel @Inject constructor( poll.resultMode == Poll.RESULT_MODE_PUBLIC && poll.votedSelf?.isNotEmpty() == true - private fun isPollCreatedByCurrentUser(poll: Poll): Boolean = currentUser.userId == poll.actorId + private fun isPollCreatedByCurrentUser(poll: Poll): Boolean = user.userId == poll.actorId fun dismissDialog() { _viewState.value = DismissDialogState diff --git a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt index c717ef2d03..7b2877e374 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/viewmodels/PollVoteViewModel.kt @@ -11,20 +11,17 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.polls.model.Poll import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class PollVoteViewModel @Inject constructor( - private val repository: PollRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class PollVoteViewModel @Inject constructor(private val repository: PollRepository) : ViewModel() { sealed interface ViewState object InitialState : ViewState @@ -50,9 +47,6 @@ class PollVoteViewModel @Inject constructor( val selectedOptions: List get() = _selectedOptions - private val currentUser = currentUserProvider.currentUser.blockingGet() - private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) - fun initVotedOptions(selectedOptions: List) { _votedOptions = selectedOptions _selectedOptions = selectedOptions @@ -70,18 +64,18 @@ class PollVoteViewModel @Inject constructor( _selectedOptions = _selectedOptions.minus(option) } - fun vote(roomToken: String, pollId: String) { + fun vote(user: User, roomToken: String, pollId: String) { if (_selectedOptions.isNotEmpty()) { _submitButtonEnabled.value = false val url = ApiUtils.getUrlForPoll( - currentUser.baseUrl!!, + user.baseUrl!!, roomToken, pollId ) repository.vote( - credentials, + ApiUtils.getCredentials(user.username, user.token), url, roomToken, pollId, diff --git a/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java b/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java index b98cad526d..7b28dff502 100644 --- a/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java +++ b/app/src/main/java/com/nextcloud/talk/presenters/MentionAutocompletePresenter.java @@ -20,10 +20,10 @@ import com.nextcloud.talk.data.user.model.User; import com.nextcloud.talk.models.json.mention.MentionDto; import com.nextcloud.talk.models.json.mention.MentionOverall; +import com.nextcloud.talk.ui.theme.HostViewThemeUtils; import com.nextcloud.talk.ui.theme.ViewThemeUtils; import com.nextcloud.talk.users.UserManager; import com.nextcloud.talk.utils.ApiUtils; -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld; import com.otaliastudios.autocomplete.RecyclerViewPresenter; import java.util.ArrayList; @@ -52,9 +52,6 @@ public class MentionAutocompletePresenter extends RecyclerViewPresenter() @@ -114,6 +111,7 @@ class ProfileActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + setUpBoundUserOrFinish() ?: return val restoredEdit = savedInstanceState?.getBoolean(KEY_EDIT_MODE) ?: false profileUiState = profileUiState.copy(isEditMode = restoredEdit) @@ -172,7 +170,11 @@ class ProfileActivity : BaseActivity() { override fun onResume() { super.onResume() - currentUser = currentUserProviderOld.currentUser.blockingGet() + currentUser = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { + // The account was removed while this screen was in the background. + finish() + return + } val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) pickImage = PickImage(this, currentUser) diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt b/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt index 3c6a670859..1da3539e20 100644 --- a/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt @@ -10,22 +10,19 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.raisehand.RequestAssistanceModel import com.nextcloud.talk.raisehand.RequestAssistanceRepository import com.nextcloud.talk.raisehand.WithdrawRequestAssistanceModel import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class RaiseHandViewModel @Inject constructor( - private val repository: RequestAssistanceRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class RaiseHandViewModel @Inject constructor(private val repository: RequestAssistanceRepository) : ViewModel() { @Inject lateinit var userManager: UserManager @@ -39,7 +36,7 @@ class RaiseHandViewModel @Inject constructor( object LoweredHandState : ViewState object ErrorState : ViewState - private val currentUser = currentUserProvider.currentUser.blockingGet() + private lateinit var currentUser: User private val _viewState: MutableLiveData = MutableLiveData(LoweredHandState) val viewState: LiveData @@ -101,7 +98,8 @@ class RaiseHandViewModel @Inject constructor( } } - fun setData(roomToken: String, isBreakoutRoom: Boolean) { + fun setData(user: User, roomToken: String, isBreakoutRoom: Boolean) { + this.currentUser = user this.roomToken = roomToken this.isBreakoutRoom = isBreakoutRoom } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt index 42873ec647..fca8cacb07 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt @@ -34,7 +34,6 @@ import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.Observer import io.reactivex.Single @@ -50,9 +49,6 @@ class DirectReplyReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -74,8 +70,11 @@ class DirectReplyReceiver : BroadcastReceiver() { systemNotificationId = intent!!.getIntExtra(KEY_SYSTEM_NOTIFICATION_ID, 0) roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } replyMessage = getMessageText(intent) sendDirectReply() diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt index e9fba339af..cdfa161082 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DismissRecordingAvailableReceiver.kt @@ -21,7 +21,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -35,9 +34,6 @@ class DismissRecordingAvailableReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -58,8 +54,11 @@ class DismissRecordingAvailableReceiver : BroadcastReceiver() { systemNotificationId = intent!!.getIntExtra(KEY_SYSTEM_NOTIFICATION_ID, 0) link = intent.getStringExtra(BundleKeys.KEY_DISMISS_RECORDING_URL) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } dismissNcRecordingAvailableNotification() } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt index fdaa213d52..f83da396e6 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/MarkAsReadReceiver.kt @@ -23,7 +23,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MESSAGE_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -37,9 +36,6 @@ class MarkAsReadReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -62,8 +58,11 @@ class MarkAsReadReceiver : BroadcastReceiver() { roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) messageId = intent.getIntExtra(KEY_MESSAGE_ID, 0) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } markAsRead() } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt index 1c48a6cad8..9ec1630ee9 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/ShareRecordingToChatReceiver.kt @@ -24,7 +24,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -38,9 +37,6 @@ class ShareRecordingToChatReceiver : BroadcastReceiver() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var ncApi: NcApi @@ -58,8 +54,11 @@ class ShareRecordingToChatReceiver : BroadcastReceiver() { systemNotificationId = intent!!.getIntExtra(KEY_SYSTEM_NOTIFICATION_ID, 0) link = intent.getStringExtra(BundleKeys.KEY_SHARE_RECORDING_TO_CHAT_URL) - val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, currentUserProvider.currentUser.blockingGet().id!!) - currentUser = runBlocking { userManager.getUserWithId(id) }!! + val id = intent.getLongExtra(KEY_INTERNAL_USER_ID, 0L) + currentUser = runBlocking { userManager.getUserWithId(id) } ?: run { + Log.e(TAG, "No user found for id $id") + return + } shareRecordingToChat() } diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt index bff575ea17..0d4a461278 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/activities/RemoteFileBrowserActivity.kt @@ -10,58 +10,50 @@ package com.nextcloud.talk.remotefilebrowser.activities import android.app.Activity import android.content.Intent -import android.os.Build import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment -import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import autodagger.AutoInjector import com.nextcloud.talk.R +import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityRemoteFileBrowserBinding import com.nextcloud.talk.remotefilebrowser.SelectionInterface import com.nextcloud.talk.remotefilebrowser.adapters.RemoteFileBrowserItemsAdapter import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.ui.dialog.SortingOrderDialogFragment -import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileSortOrder import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MIME_TYPE_FILTER -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class RemoteFileBrowserActivity : - AppCompatActivity(), + BaseActivity(), SelectionInterface, SwipeRefreshLayout.OnRefreshListener { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: RemoteFileBrowserItemsViewModel.Factory - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils + private lateinit var user: User @Inject lateinit var dateUtils: DateUtils private lateinit var binding: ActivityRemoteFileBrowserBinding - private lateinit var viewModel: RemoteFileBrowserItemsViewModel + private val viewModel: RemoteFileBrowserItemsViewModel by assistedViewModels { viewModelFactory.build(user) } private var filesSelectionDoneMenuItem: MenuItem? = null @@ -75,6 +67,7 @@ class RemoteFileBrowserActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + user = setUpBoundUserOrFinish() ?: return binding = ActivityRemoteFileBrowserBinding.inflate(layoutInflater) setSupportActionBar(binding.remoteFileBrowserItemsToolbar) @@ -112,8 +105,6 @@ class RemoteFileBrowserActivity : } private fun initViewModel(mimeTypeSelectionFilter: String?) { - viewModel = ViewModelProvider(this, viewModelFactory)[RemoteFileBrowserItemsViewModel::class.java] - viewModel.viewState.observe(this) { state -> clearEmptyLoading() when (state) { @@ -167,7 +158,7 @@ class RemoteFileBrowserActivity : val adapter = RemoteFileBrowserItemsAdapter( showGrid = showGrid, mimeTypeSelectionFilter = mimeTypeSelectionFilter, - user = currentUserProvider.currentUser.blockingGet(), + user = user, selectionInterface = this, viewThemeUtils = viewThemeUtils, dateUtils = dateUtils, @@ -246,21 +237,6 @@ class RemoteFileBrowserActivity : binding.recyclerView.visibility = View.VISIBLE } - fun initSystemBars() { - val decorView = window.decorView - decorView.setOnApplyWindowInsetsListener { view, insets -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - val systemBars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() or - WindowInsetsCompat.Type.displayCutout() - ) - view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - } - insets - } - ViewCompat.requestApplyInsets(decorView) - } - override fun onRefresh() { refreshCurrentPath() } diff --git a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt index 7615d7dc74..10cd907c17 100644 --- a/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/remotefilebrowser/viewmodels/RemoteFileBrowserItemsViewModel.kt @@ -12,12 +12,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.nextcloud.talk.R +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem import com.nextcloud.talk.remotefilebrowser.repositories.RemoteFileBrowserItemsRepository import com.nextcloud.talk.utils.FileSortOrder import com.nextcloud.talk.utils.Mimetype.FOLDER -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preferences.AppPreferencesImpl +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -28,7 +31,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import java.io.File -import javax.inject.Inject /** * @startuml @@ -44,12 +46,10 @@ import javax.inject.Inject * @enduml */ @OptIn(ExperimentalCoroutinesApi::class) -class RemoteFileBrowserItemsViewModel -@Inject -constructor( +class RemoteFileBrowserItemsViewModel @AssistedInject constructor( private val repository: RemoteFileBrowserItemsRepository, private val appPreferences: AppPreferencesImpl, - private val currentUserProvider: CurrentUserProviderOld + @Assisted private val currentUser: User ) : ViewModel() { sealed interface ViewState @@ -59,8 +59,6 @@ constructor( class LoadedState(val items: List) : ViewState class FinishState(val selectedPaths: Set) : ViewState - private val currentUser = currentUserProvider.currentUser.blockingGet() - private val initialSortOrder = FileSortOrder.getFileSortOrder(appPreferences.sorting) private var sortingFlow: Flow @@ -221,6 +219,11 @@ constructor( fun isPathSelected(path: String): Boolean = selectedPaths.value?.contains(path) == true || shouldPathBeSelectedDueToParent(path) + @AssistedFactory + interface Factory { + fun build(user: User): RemoteFileBrowserItemsViewModel + } + companion object { private val TAG = RemoteFileBrowserItemsViewModel::class.simpleName private const val ROOT_PATH = "/" diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 7bf5bda7f8..48ac99d0b6 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -43,6 +43,7 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -80,7 +81,6 @@ import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.userprofile.UserProfileOverall import com.nextcloud.talk.profile.ProfileActivity import com.nextcloud.talk.ui.dialog.SetPhoneNumberDialogFragment -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -134,9 +134,6 @@ class SettingsActivity : @Inject lateinit var ncApiCoroutines: NcApiCoroutines - @Inject - lateinit var userManager: UserManager - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -175,6 +172,7 @@ class SettingsActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + setUpBoundUserOrFinish() ?: return networkMonitor.isOnlineLiveData.observe(this) { online -> isOnline.value = online handleNetworkChange(isOnline.value) @@ -187,7 +185,7 @@ class SettingsActivity : binding.avatarImage.let { ViewCompat.setTransitionName(it, "userAvatar.transitionTag") } - getCurrentUser() + if (!getCurrentUser()) return handleIntent(intent) setupLicenceSetting(isOnline.value) @@ -277,6 +275,7 @@ class SettingsActivity : binding.settingsName.visibility = View.VISIBLE binding.settingsName.setOnClickListener { val intent = Intent(this, ProfileActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(intent) } @@ -316,8 +315,7 @@ class SettingsActivity : WorkManager.getInstance(context).getWorkInfoByIdLiveData(capabilitiesWork.id) .observe(this) { workInfo -> - if (workInfo?.state == WorkInfo.State.SUCCEEDED) { - getCurrentUser() + if (workInfo?.state == WorkInfo.State.SUCCEEDED && getCurrentUser()) { setupCheckables(isOnline) } } @@ -335,9 +333,17 @@ class SettingsActivity : viewThemeUtils.material.themeToolbar(binding.settingsToolbar) } - private fun getCurrentUser() { - currentUser = currentUserProviderOld.currentUser.blockingGet() - credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + /** + * Loads the account of this screen again. Finishes the activity and returns false if it was removed meanwhile. + */ + private fun getCurrentUser(): Boolean { + val user = runBlocking { userManager.getUserWithId(resolveUserIdFromIntent()) } ?: run { + finish() + return false + } + currentUser = user + credentials = ApiUtils.getCredentials(user.username, user.token) + return true } private fun setupPhoneBookIntegration(isOnline: Boolean) { @@ -631,6 +637,7 @@ class SettingsActivity : } .setNeutralButton(R.string.nc_diagnosis_dialog_open_diagnosis) { _, _ -> val intent = Intent(context, DiagnosisActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id) startActivity(intent) } viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) @@ -656,14 +663,14 @@ class SettingsActivity : private fun setupServerNotificationAppCheck() { val serverNotificationAppInstalled = - currentUserProviderOld.currentUser.blockingGet() + currentUser!! .capabilities?.notificationsCapability?.features?.isNotEmpty() ?: false if (!serverNotificationAppInstalled) { binding.settingsServerNotificationAppWrapper.visibility = View.VISIBLE val description = context.getString(R.string.nc_settings_contact_admin_of) + LINEBREAK + - currentUserProviderOld.currentUser.blockingGet().baseUrl!! + currentUser!!.baseUrl!! binding.settingsServerNotificationAppDescription.text = description if (openedByNotificationWarning) { @@ -692,7 +699,9 @@ class SettingsActivity : private fun setupDiagnosis() { binding.diagnosisWrapper.setOnClickListener { - startActivity(Intent(context, DiagnosisActivity::class.java)) + val intent = Intent(context, DiagnosisActivity::class.java) + intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, currentUser!!.id) + startActivity(intent) } binding.logsWrapper.setOnClickListener { startActivity(Intent(context, LogsActivity::class.java)) @@ -776,8 +785,9 @@ class SettingsActivity : } Log.d(TAG, "host: $host and port: $port") currentUser!!.clientCertificate = finalAlias + val userId = currentUser!!.id!! lifecycleScope.launch { - userManager.updateOrCreateUser(currentUser!!) + userManager.updateClientCertificate(userId, finalAlias) } }, arrayOf("RSA", "EC"), @@ -1094,8 +1104,9 @@ class SettingsActivity : } if ((!TextUtils.isEmpty(displayName) && !(displayName == currentUser!!.displayName))) { currentUser!!.displayName = displayName + val userId = currentUser!!.id!! lifecycleScope.launch { - userManager.updateOrCreateUser(currentUser!!) + userManager.updateDisplayName(userId, displayName) } binding.nameText.text = currentUser!!.displayName } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index d80d0dd40e..ac080d75cc 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -25,7 +25,7 @@ import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel -import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySharedItemsBinding import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter @@ -46,6 +46,8 @@ class SharedItemsActivity : BaseActivity() { @Inject lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory + private lateinit var conversationUser: User + val roomToken: String by lazy { intent.getStringExtra(KEY_ROOM_TOKEN) ?: error("roomToken missing") @@ -59,17 +61,15 @@ class SharedItemsActivity : BaseActivity() { } } - val chatViewModel: ChatViewModel by viewModels { - ViewModelFactoryWithParams(ChatViewModel::class.java) { - chatViewModelFactory.build( - roomToken, - conversationThreadId - ) - } + val chatViewModel: ChatViewModel by assistedViewModels { + chatViewModelFactory.build( + conversationUser, + roomToken, + conversationThreadId + ) } - @Inject - lateinit var contextChatViewModel: ContextChatViewModel + private val contextChatViewModel: ContextChatViewModel by viewModels { viewModelFactory } private lateinit var binding: ActivitySharedItemsBinding private lateinit var viewModel: SharedItemsViewModel @@ -77,10 +77,9 @@ class SharedItemsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + conversationUser = setUpBoundUserOrFinish() ?: return val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) - val user = currentUserProviderOld.currentUser.blockingGet() - val isUserConversationOwnerOrModerator = intent.getBooleanExtra(KEY_USER_IS_OWNER_OR_MODERATOR, false) val isOne2One = intent.getBooleanExtra(KEY_IS_ONE_2_ONE, false) @@ -99,7 +98,7 @@ class SharedItemsActivity : BaseActivity() { viewModel = ViewModelProvider(this, viewModelFactory)[SharedItemsViewModel::class.java] viewModel.viewState.observe(this) { state -> - handleModelChange(state, user, roomToken, isUserConversationOwnerOrModerator, isOne2One) + handleModelChange(state, conversationUser, roomToken, isUserConversationOwnerOrModerator, isOne2One) } binding.imageRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -111,7 +110,7 @@ class SharedItemsActivity : BaseActivity() { } }) - viewModel.initialize(user, roomToken) + viewModel.initialize(conversationUser, roomToken) } private fun handleModelChange( @@ -162,18 +161,11 @@ class SharedItemsActivity : BaseActivity() { viewThemeUtils.material.themeTabLayoutOnSurface(binding.sharedItemsTabs) } - fun startContextChatWindowForMessage( - credentials: String?, - baseUrl: String?, - roomToken: String, - messageId: String?, - threadId: String? - ) { + fun startContextChatWindowForMessage(user: User, roomToken: String, messageId: String?, threadId: String?) { binding.genericComposeView.apply { setContent { contextChatViewModel.getContextForChatMessages( - credentials = credentials!!, - baseUrl = baseUrl!!, + user = user, token = roomToken, threadId = threadId, messageId = messageId!!, @@ -181,7 +173,7 @@ class SharedItemsActivity : BaseActivity() { ) // Context chat Compose integration is pending for this screen. // ContextChatView( - // user = currentUserProviderOld.currentUser.blockingGet(), + // user = user, // context, // viewThemeUtils = viewThemeUtils, // contextChatViewModel diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index d62b965d8c..fa360a8d62 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.shareditems.adapters import android.content.Context -import android.content.Intent import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -125,23 +124,21 @@ class SharedItemsAdapter( .sortedBy { it.messageId } .map { it.toMediaViewerItem() } .capSeedAroundMessage(item.messageId) - context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) + context.startActivity( + MediaViewerActivity.newIntent(context, user.id!!, roomToken, seedItems, item.messageId) + ) } private fun openInChat(item: SharedFileItem, context: Context) { - val intent = Intent(context, ChatActivity::class.java).apply { - putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + val intent = ChatActivity.createIntent(context, user.id!!, roomToken).apply { putExtra(BundleKeys.KEY_MESSAGE_ID, item.messageId) } context.startActivity(intent) } private fun openMessage(item: SharedItem, context: Context) { - val credentials = ApiUtils.getCredentials(user.username, user.token) - val baseUrl = user.baseUrl (context as SharedItemsActivity).startContextChatWindowForMessage( - credentials, - baseUrl, + user, roomToken, item.id, null diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt index bd4f64442e..eed91f3b87 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepository.kt @@ -19,5 +19,8 @@ interface SharedItemsRepository { fun availableTypes(parameters: Parameters): Observable> + /** + * [userName] is the login name of the account, which authenticates the requests together with [userToken]. + */ data class Parameters(val userName: String, val userToken: String, val baseUrl: String, val roomToken: String) } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt index d083ca9dde..68ebf27368 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt @@ -26,6 +26,7 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils import io.reactivex.Observable +import retrofit2.HttpException import retrofit2.Response import java.util.Locale import javax.inject.Inject @@ -65,6 +66,9 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr chatLastGiven = response.headers()["x-chat-last-given"]!!.toInt() } + if (!response.isSuccessful) { + throw HttpException(response) + } val mediaItems = response.body()!!.ocs!!.data if (mediaItems != null) { for (it in mediaItems) { diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt b/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt index 3de476c563..cd2f672013 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/viewmodels/SharedItemsViewModel.kt @@ -41,7 +41,8 @@ class SharedItemsViewModel @Inject constructor(private val repository: SharedIte fun initialize(user: User, roomToken: String) { repositoryParameters = SharedItemsRepository.Parameters( - user.userId!!, + // The login name, as it authenticates the requests. The user id can differ from it. + user.username!!, user.token!!, user.baseUrl!!, roomToken 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 a457002283..18280571ac 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.threadsoverview -import android.content.Intent import android.os.Bundle import android.text.format.DateUtils import androidx.activity.compose.setContent @@ -36,7 +35,6 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity @@ -45,12 +43,12 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.dagger.modules.assistedViewModels import com.nextcloud.talk.data.database.mappers.toDomainModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.threads.ThreadInfoDto import com.nextcloud.talk.threadsoverview.components.ThreadRow import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import javax.inject.Inject @@ -58,15 +56,16 @@ import javax.inject.Inject class ThreadsOverviewActivity : BaseActivity() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var threadsOverviewViewModelFactory: ThreadsOverviewViewModel.Factory @Inject lateinit var ncApi: NcApi - @Inject - lateinit var userManager: UserManager + private lateinit var user: User - lateinit var threadsOverviewViewModel: ThreadsOverviewViewModel + val threadsOverviewViewModel: ThreadsOverviewViewModel by assistedViewModels { + threadsOverviewViewModelFactory.build(user) + } var threadsSourceUrl: String = "" var appbarTitle: String = "" @@ -74,10 +73,7 @@ class ThreadsOverviewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - threadsOverviewViewModel = ViewModelProvider( - this, - viewModelFactory - )[ThreadsOverviewViewModel::class.java] + user = setUpBoundUserOrFinish() ?: return val colorScheme = viewThemeUtils.getColorScheme(this) @@ -124,11 +120,8 @@ class ThreadsOverviewActivity : BaseActivity() { } private fun navigateToChatActivity(roomToken: String, threadId: Int) { - val bundle = Bundle() - bundle.putString(KEY_ROOM_TOKEN, roomToken) - bundle.putLong(KEY_THREAD_ID, threadId.toLong()) - val chatIntent = Intent(context, ChatActivity::class.java) - chatIntent.putExtras(bundle) + val chatIntent = ChatActivity.createIntent(context, resolveUserIdFromIntent(), roomToken) + chatIntent.putExtra(KEY_THREAD_ID, threadId.toLong()) startActivity(chatIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt index 0bc6c1bc50..c45a7fe9e0 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/viewmodels/ThreadsOverviewViewModel.kt @@ -19,22 +19,21 @@ import com.nextcloud.talk.models.json.threads.ThreadsOverall import com.nextcloud.talk.threadsoverview.data.ThreadsRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.UserIdUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("TooGenericExceptionCaught") -class ThreadsOverviewViewModel @Inject constructor( +class ThreadsOverviewViewModel @AssistedInject constructor( private val threadsRepository: ThreadsRepository, - private val currentUserProvider: CurrentUserProviderOld, private val arbitraryStorageManager: ArbitraryStorageManager, - private val logger: Logger + private val logger: Logger, + @Assisted val currentUser: User ) : ViewModel() { - private val _currentUser = currentUserProvider.currentUser.blockingGet() - val currentUser: User = _currentUser - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" private val _threadsListState = MutableStateFlow(ThreadsListUiState.None) val threadsListState: StateFlow = _threadsListState @@ -59,7 +58,7 @@ class ThreadsOverviewViewModel @Inject constructor( private fun updateFollowedThreadsIndicator(url: String, threads: ThreadsOverall) { val subscribedThreadsEndpoint = "subscribed-threads" if (url.contains(subscribedThreadsEndpoint) && threads.ocs?.data?.isEmpty() == true) { - val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet()) + val accountId = UserIdUtils.getIdForUser(currentUser) arbitraryStorageManager.storeStorageSetting( accountId, FOLLOWED_THREADS_EXIST, @@ -81,6 +80,11 @@ class ThreadsOverviewViewModel @Inject constructor( data class Error(val exception: Exception) : ThreadsListUiState() } + @AssistedFactory + interface Factory { + fun build(user: User): ThreadsOverviewViewModel + } + companion object { private val TAG = ThreadsOverviewViewModel::class.java.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt index 494ac7d60e..9b6b83763d 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/ui/TranslateActivity.kt @@ -18,16 +18,16 @@ import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import androidx.core.graphics.drawable.toDrawable -import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.dagger.modules.assistedViewModels +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityTranslateBinding import com.nextcloud.talk.translate.repositories.model.LanguageDto import com.nextcloud.talk.translate.viewmodels.TranslateViewModel -import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.bundle.BundleKeys import java.util.Locale import javax.inject.Inject @@ -37,12 +37,11 @@ import javax.inject.Inject class TranslateActivity : BaseActivity() { @Inject - lateinit var userManager: UserManager + lateinit var viewModelFactory: TranslateViewModel.Factory - @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + private lateinit var user: User - lateinit var viewModel: TranslateViewModel + val viewModel: TranslateViewModel by assistedViewModels { viewModelFactory.build(user) } lateinit var binding: ActivityTranslateBinding private var toLanguages: Array? = null @@ -53,9 +52,9 @@ class TranslateActivity : BaseActivity() { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + user = setUpBoundUserOrFinish() ?: return binding = ActivityTranslateBinding.inflate(layoutInflater) - viewModel = ViewModelProvider(this, viewModelFactory)[TranslateViewModel::class.java] viewModel.viewState.observe(this) { state -> when (state) { diff --git a/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt b/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt index 6e561fc703..c19476f7ba 100644 --- a/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/translate/viewmodels/TranslateViewModel.kt @@ -16,16 +16,17 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.translate.repositories.TranslateRepository import com.nextcloud.talk.translate.repositories.model.LanguageDto import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import javax.inject.Inject -class TranslateViewModel @Inject constructor( +class TranslateViewModel @AssistedInject constructor( private val repository: TranslateRepository, - private val currentUserProvider: CurrentUserProviderOld + @Assisted private val currentUser: User ) : ViewModel() { sealed interface ViewState @@ -44,7 +45,6 @@ class TranslateViewModel @Inject constructor( get() = _viewState fun translateMessage(toLanguage: String, fromLanguage: String?, text: String) { - val currentUser: User = currentUserProvider.currentUser.blockingGet() val authorization: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! val url: String = ApiUtils.getUrlForTranslation(currentUser.baseUrl!!) val calculatedFromLanguage = @@ -67,7 +67,6 @@ class TranslateViewModel @Inject constructor( } fun getLanguages() { - val currentUser: User = currentUserProvider.currentUser.blockingGet() val authorization: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! val url: String = ApiUtils.getUrlForLanguages(currentUser.baseUrl!!) Log.d(TAG, "URL is: $url") @@ -113,6 +112,12 @@ class TranslateViewModel @Inject constructor( // nothing? } } + + @AssistedFactory + interface Factory { + fun build(user: User): TranslateViewModel + } + companion object { private val TAG = TranslateViewModel::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt b/app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt new file mode 100644 index 0000000000..ffcadc3c55 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/ImageAuthHeader.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import coil.request.ImageRequest + +/** + * Credentials of the account a screen belongs to, for images it loads from that account's server, like file previews + * or conversation avatars. Requests with credentials don't use session cookies, so these images need them. + */ +val LocalImageAuthHeader = staticCompositionLocalOf { null } + +/** + * Adds [authHeader] as Authorization header, if there is one. + */ +fun ImageRequest.Builder.withAuthHeader(authHeader: String?): ImageRequest.Builder = + apply { authHeader?.let { addHeader("Authorization", it) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt b/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt index 348138e191..53a69b407a 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/PlaybackSpeedControl.kt @@ -11,6 +11,7 @@ import android.content.Context import android.util.AttributeSet import autodagger.AutoInjector import com.google.android.material.button.MaterialButton +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import java.util.Locale import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -35,6 +36,7 @@ class PlaybackSpeedControl @JvmOverloads constructor( init { NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(context, viewThemeUtils) text = currentSpeed.label viewThemeUtils.material.colorMaterialButtonText(this) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index 0b071dcbdc..2b6584749a 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -99,6 +99,8 @@ import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.TextMatchers import java.time.LocalDate +import coil.request.ImageRequest +import com.nextcloud.talk.ui.withAuthHeader private val regularTextSize = 16.sp private val timeTextSize = 12.sp @@ -907,7 +909,10 @@ private fun QuoteMediaRow(message: ChatMessageUi, content: MessageTypeContent.Me ) { if (content.previewUrl != null) { AsyncImage( - model = content.previewUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(content.previewUrl) + .withAuthHeader(content.authHeader) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt index c8ef6abb15..3998c90941 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -34,6 +34,7 @@ import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.contacts.load import com.nextcloud.talk.models.json.opengraph.OpenGraphObjectDto import com.nextcloud.talk.ui.theme.LocalOpenGraphFetcher +import com.nextcloud.talk.utils.UriUtils import androidx.core.net.toUri private val previewImageHeight = 120.dp @@ -70,7 +71,8 @@ fun LinkMessage( LinkPreviewCard( og = og, url = og.link?.takeIf { it.isNotBlank() } ?: typeContent.url, - highlightSearchTerm = highlightSearchTerm + highlightSearchTerm = highlightSearchTerm, + typeContent = typeContent ) } } @@ -79,7 +81,12 @@ fun LinkMessage( } @Composable -private fun LinkPreviewCard(og: OpenGraphObjectDto, url: String, highlightSearchTerm: String?) { +private fun LinkPreviewCard( + og: OpenGraphObjectDto, + url: String, + highlightSearchTerm: String?, + typeContent: MessageTypeContent.LinkPreview +) { val context = LocalContext.current Surface( shape = MaterialTheme.shapes.small, @@ -97,7 +104,7 @@ private fun LinkPreviewCard(og: OpenGraphObjectDto, url: String, highlightSearch og = og, highlightSearchTerm = highlightSearchTerm ) - LinkPreviewImage(thumbUrl = og.thumb, context = context) + LinkPreviewImage(thumbUrl = og.thumb, context = context, typeContent = typeContent) } } } @@ -140,12 +147,19 @@ private fun LinkPreviewTexts(og: OpenGraphObjectDto, highlightSearchTerm: String } @Composable -private fun LinkPreviewImage(thumbUrl: String?, context: android.content.Context) { +private fun LinkPreviewImage( + thumbUrl: String?, + context: android.content.Context, + typeContent: MessageTypeContent.LinkPreview +) { thumbUrl?.takeIf { it.isNotBlank() }?.let { + // Credentials only for images of the account's own server, never for external ones. + val isOwnServer = typeContent.serverBaseUrl?.let { baseUrl -> UriUtils.isOnServer(it, baseUrl) } == true val loadedImage = load( imageUri = it, context = context, - errorPlaceholderImage = R.drawable.ic_mimetype_image + errorPlaceholderImage = R.drawable.ic_mimetype_image, + authHeader = if (isOwnServer) typeContent.authHeader else null ) AsyncImage( model = loadedImage, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt index 5c7b571b5f..285ba26466 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -434,7 +434,8 @@ private fun SyncedMediaTileContent( imageUri = typeContent.previewUrl, context = context, errorPlaceholderImage = typeContent.drawableResourceId, - animated = typeContent.animateGif + animated = typeContent.animateGif, + authHeader = typeContent.authHeader ) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt index ba56c5a954..31f1a6b742 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt @@ -266,7 +266,8 @@ fun MediaMessage( imageUri = retryAwarePreviewUrl, context = context, errorPlaceholderImage = typeContent.drawableResourceId, - animated = typeContent.animateGif + animated = typeContent.animateGif, + authHeader = typeContent.authHeader ) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt index f2a36063e1..0f17072a9d 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToDialogFragment.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider @@ -25,10 +26,13 @@ import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserSuccessStateChooseAccountShareTo import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.net.CookieManager @@ -38,7 +42,7 @@ import javax.inject.Inject class ChooseAccountShareToDialogFragment : DialogFragment() { @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + lateinit var viewModelFactory: ChooseAccountShareToViewModel.Factory @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -62,7 +66,13 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { super.onViewCreated(view, savedInstanceState) NextcloudTalkApplication.Companion.sharedApplication!!.componentApplication.inject(this) - viewModel = ViewModelProvider(this, viewModelFactory)[ChooseAccountShareToViewModel::class.java] + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) + // The account of the conversation list this chooser belongs to, not the default account. + val user = BundleCompat.getParcelable(requireArguments(), KEY_USER, User::class.java)!! + viewModel = ViewModelProvider( + this, + ViewModelFactoryWithParams(ChooseAccountShareToViewModel::class.java) { viewModelFactory.build(user) } + )[ChooseAccountShareToViewModel::class.java] val otherUsers = mutableStateOf>(emptyList()) @@ -75,7 +85,7 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { } is SwitchUserSuccessStateChooseAccountShareTo -> { cookieManager.cookieStore.removeAll() - activity?.recreate() + (activity as? ConversationsListActivity)?.relaunchForAccount(state.user.id!!) dismiss() } else -> {} @@ -110,6 +120,11 @@ class ChooseAccountShareToDialogFragment : DialogFragment() { companion object { const val TAG = "ChooseAccountShareToDialogFragment" - fun newInstance(): ChooseAccountShareToDialogFragment = ChooseAccountShareToDialogFragment() + private const val KEY_USER = "KEY_USER" + + fun newInstance(user: User): ChooseAccountShareToDialogFragment = + ChooseAccountShareToDialogFragment().apply { + arguments = Bundle().apply { putParcelable(KEY_USER, user) } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt index dbd8a25d01..fadc244d7f 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/ChooseAccountShareToViewModel.kt @@ -17,21 +17,22 @@ import com.nextcloud.talk.ui.chooseaccount.model.LoadUsersSuccessStateChooseAcco import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserErrorStateChooseAccountShareTo import com.nextcloud.talk.ui.chooseaccount.model.SwitchUserSuccessStateChooseAccountShareTo import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import javax.inject.Inject -class ChooseAccountShareToViewModel @Inject constructor( +/** + * Lets the conversation list of [currentUser] continue a pending share with another account. + */ +class ChooseAccountShareToViewModel @AssistedInject constructor( private val userManager: UserManager, - currentUserProvider: CurrentUserProvider + @Assisted val currentUser: User ) : ViewModel() { - val currentUser: User? = runBlocking { currentUserProvider.getCurrentUser() }.getOrNull() - private val _chooseAccountShareToViewState: MutableStateFlow = MutableStateFlow(LoadUsersStartStateChooseAccountShareTo) val chooseAccountShareToViewState: StateFlow = @@ -44,7 +45,7 @@ class ChooseAccountShareToViewModel @Inject constructor( try { val users = userManager.getUsers() _chooseAccountShareToViewState.value = - LoadUsersSuccessStateChooseAccountShareTo(users.filter { !it.current }) + LoadUsersSuccessStateChooseAccountShareTo(users.filter { it.id != currentUser.id }) } catch (e: Exception) { Log.e(TAG, "Error loading users", e) _chooseAccountShareToViewState.value = LoadUsersSuccessStateChooseAccountShareTo(emptyList()) @@ -59,7 +60,7 @@ class ChooseAccountShareToViewModel @Inject constructor( val success = userManager.setUserAsActive(user) _chooseAccountShareToViewState.value = if (success) { - SwitchUserSuccessStateChooseAccountShareTo + SwitchUserSuccessStateChooseAccountShareTo(user) } else { SwitchUserErrorStateChooseAccountShareTo } @@ -70,6 +71,11 @@ class ChooseAccountShareToViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun build(user: User): ChooseAccountShareToViewModel + } + companion object { private val TAG = ChooseAccountShareToViewModel::class.simpleName } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt index ec79369ab2..c33043c910 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chooseaccount/model/ChooseAccountShareToViewState.kt @@ -13,5 +13,5 @@ sealed interface ChooseAccountShareToViewState object LoadUsersStartStateChooseAccountShareTo : ChooseAccountShareToViewState open class LoadUsersSuccessStateChooseAccountShareTo(val users: List) : ChooseAccountShareToViewState -object SwitchUserSuccessStateChooseAccountShareTo : ChooseAccountShareToViewState +class SwitchUserSuccessStateChooseAccountShareTo(val user: User) : ChooseAccountShareToViewState object SwitchUserErrorStateChooseAccountShareTo : ChooseAccountShareToViewState diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt index 1f0cf9512f..a1cdb1e73c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.databinding.DialogAttachmentBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures import javax.inject.Inject @@ -35,6 +36,7 @@ class AttachmentDialog(val activity: Activity, var chatActivity: ChatActivity) : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) dialogAttachmentBinding = DialogAttachmentBinding.inflate(layoutInflater) setContentView(dialogAttachmentBinding.root) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt index 659599fc28..e1eab531df 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt @@ -19,6 +19,7 @@ import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogAudioOutputBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.webrtc.WebRtcAudioManager import javax.inject.Inject @@ -33,6 +34,7 @@ class AudioOutputDialog(val callActivity: CallActivity) : BottomSheetDialog(call override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(callActivity, viewThemeUtils) dialogAudioOutputBinding = DialogAudioOutputBinding.inflate(layoutInflater) setContentView(dialogAudioOutputBinding.root) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt index 3842ff06d6..f2baf8b63c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt @@ -63,10 +63,11 @@ import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.dialog.DateTimeCompose.Companion.HALF_WEIGHT import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preview.ComposePreviewUtils import java.time.DayOfWeek import java.time.Instant @@ -79,21 +80,17 @@ import java.time.temporal.TemporalAdjusters.nextOrSame import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { +class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel, val user: User) { private var timeState = mutableStateOf(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.MIN)) init { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = currentUserProvider.currentUser.blockingGet() val roomToken = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!! val messageId = bundle.getInt(BundleKeys.KEY_MESSAGE_ID) val apiVersion = bundle.getInt(BundleKeys.KEY_CHAT_API_VERSION) chatViewModel.getReminder(user, roomToken, messageId.toString(), apiVersion) } - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -103,7 +100,7 @@ class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { return } - val colorScheme = viewThemeUtils.getColorScheme(context) + val colorScheme = hostViewThemeUtils(context, viewThemeUtils).getColorScheme(context) val isCollapsed = remember { mutableStateOf(true) } MaterialTheme(colorScheme = colorScheme) { @@ -141,7 +138,6 @@ class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { ) { TextButton( onClick = { - val user = currentUserProvider.currentUser.blockingGet() val roomToken = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!! val messageId = bundle.getInt(BundleKeys.KEY_MESSAGE_ID) val apiVersion = bundle.getInt(BundleKeys.KEY_CHAT_API_VERSION) @@ -169,7 +165,6 @@ class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { TextButton( onClick = { - val user = currentUserProvider.currentUser.blockingGet() val roomToken = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!! val messageId = bundle.getInt(BundleKeys.KEY_MESSAGE_ID) val apiVersion = bundle.getInt(BundleKeys.KEY_CHAT_API_VERSION) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt index 6647a03ed4..ab7ea2f153 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DialogBanListFragment.kt @@ -12,6 +12,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter +import androidx.core.os.BundleCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment @@ -26,11 +27,17 @@ import com.nextcloud.talk.databinding.BanItemListBinding import com.nextcloud.talk.databinding.FragmentDialogBanListBinding import com.nextcloud.talk.models.json.participants.TalkBanDto import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DialogBanListFragment(val roomToken: String) : DialogFragment() { +class DialogBanListFragment : DialogFragment() { + + // Read from the arguments, so the fragment can be recreated by the system after rotation or process death. + private val roomToken: String by lazy { requireArguments().getString(ROOM_TOKEN_ARG)!! } + private val conversationUser: User by lazy { + BundleCompat.getParcelable(requireArguments(), USER_ARG, User::class.java)!! + } lateinit var binding: FragmentDialogBanListBinding @@ -40,11 +47,7 @@ class DialogBanListFragment(val roomToken: String) : DialogFragment() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - lateinit var viewModel: ConversationInfoViewModel - private lateinit var conversationUser: User private val adapter = object : BaseAdapter() { private var bans: List = mutableListOf() @@ -87,10 +90,10 @@ class DialogBanListFragment(val roomToken: String) : DialogFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) binding = FragmentDialogBanListBinding.inflate(layoutInflater) viewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] - conversationUser = currentUserProvider.currentUser.blockingGet() themeView() initObservers() @@ -144,8 +147,17 @@ class DialogBanListFragment(val roomToken: String) : DialogFragment() { } companion object { + private const val ROOM_TOKEN_ARG = "ROOM_TOKEN_ARG" + private const val USER_ARG = "USER_ARG" + @JvmStatic - fun newInstance(roomToken: String) = DialogBanListFragment(roomToken) + fun newInstance(roomToken: String, user: User) = + DialogBanListFragment().apply { + arguments = Bundle().apply { + putString(ROOM_TOKEN_ARG, roomToken) + putParcelable(USER_ARG, user) + } + } const val ONE_SEC = 1000L } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt index aa6fc1245d..f5ac1f34fe 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FilterConversationFragment.kt @@ -13,6 +13,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -20,12 +21,13 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogFilterConversationBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -33,9 +35,7 @@ class FilterConversationFragment : DialogFragment() { lateinit var binding: DialogFilterConversationBinding private var dialogView: View? = null private lateinit var filterState: HashMap - - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld + private lateinit var user: User @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -50,11 +50,13 @@ class FilterConversationFragment : DialogFragment() { } else { arguments?.getSerializable(FILTER_STATE_ARG) as HashMap } + user = BundleCompat.getParcelable(requireArguments(), USER_ARG, User::class.java)!! return MaterialAlertDialogBuilder(requireContext()).setView(dialogView).create() } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) setUpColors() setUpListeners() return inflater.inflate(R.layout.dialog_filter_conversation, container, false) @@ -117,7 +119,7 @@ class FilterConversationFragment : DialogFragment() { binding.mentionedFilterChip.isChecked = filterState[MENTION]!! binding.archivedFilterChip.visibility = View.GONE - currentUserProvider.currentUser.blockingGet().capabilities?.spreedCapability?.let { + user.capabilities?.spreedCapability?.let { if (hasSpreedFeatureCapability(it, SpreedFeatures.ARCHIVE_CONVERSATIONS)) { binding.archivedFilterChip.visibility = View.VISIBLE binding.archivedFilterChip.isChecked = filterState[ARCHIVE]!! @@ -127,7 +129,7 @@ class FilterConversationFragment : DialogFragment() { private fun processSubmit() { // store - val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet()) + val accountId = UserIdUtils.getIdForUser(user) val mentionValue = filterState[MENTION] == true val unreadValue = filterState[UNREAD] == true val archivedValue = filterState[ARCHIVE] == true @@ -153,12 +155,14 @@ class FilterConversationFragment : DialogFragment() { companion object { private const val FILTER_STATE_ARG = "FILTER_STATE_ARG" + private const val USER_ARG = "USER_ARG" @JvmStatic - fun newInstance(savedFilterState: MutableMap): FilterConversationFragment { + fun newInstance(savedFilterState: MutableMap, user: User): FilterConversationFragment { val filterConversationFragment = FilterConversationFragment() val args = Bundle() args.putSerializable(FILTER_STATE_ARG, HashMap(savedFilterState)) + args.putParcelable(USER_ARG, user) filterConversationFragment.arguments = args return filterConversationFragment } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt index bfc10a712b..453212ffef 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.camera.ImageSegmenterHelper import com.nextcloud.talk.databinding.DialogMoreCallActionsBinding import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.viewmodels.CallRecordingViewModel @@ -40,6 +41,7 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(callActivity, viewThemeUtils) binding = DialogMoreCallActionsBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt index 3c43eb5196..e68d91fedc 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt @@ -16,6 +16,7 @@ import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.work.Data import androidx.work.OneTimeWorkRequest +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager @@ -40,6 +41,7 @@ class SaveToStorageDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) fileName = arguments?.getString(KEY_FILE_NAME)!! } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt index e6913c4009..11d4f6e6e8 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SetPhoneNumberDialogFragment.kt @@ -23,6 +23,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogSetPhoneNumberBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.theme.hostViewThemeUtils import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -37,6 +38,7 @@ class SetPhoneNumberDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java b/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java index c23a60a880..ac7f380d70 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SortingOrderDialogFragment.java @@ -21,6 +21,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.databinding.SortingOrderFragmentBinding; +import com.nextcloud.talk.ui.theme.HostViewThemeUtils; import com.nextcloud.talk.ui.theme.ViewThemeUtils; import com.nextcloud.talk.utils.FileSortOrder; import com.nextcloud.talk.utils.preferences.AppPreferences; @@ -98,6 +99,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); + viewThemeUtils = HostViewThemeUtils.hostViewThemeUtils(getActivity(), viewThemeUtils); setupDialogElements(); setupListeners(); diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt new file mode 100644 index 0000000000..68e4ef8ef9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/HostViewThemeUtils.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +@file:JvmName("HostViewThemeUtils") + +package com.nextcloud.talk.ui.theme + +import android.content.Context +import android.content.ContextWrapper +import com.nextcloud.talk.activities.BaseActivity + +/** + * Returns the [ViewThemeUtils] of the [BaseActivity] that [context] belongs to, which is themed for the account that + * activity was started for, or [fallback] if [context] does not belong to a [BaseActivity]. + */ +fun hostViewThemeUtils(context: Context?, fallback: ViewThemeUtils): ViewThemeUtils { + var current = context + while (current is ContextWrapper) { + if (current is BaseActivity) { + return current.viewThemeUtils + } + current = current.baseContext + } + return fallback +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt index a1a57ad03c..93160d7f01 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProvider.kt @@ -14,5 +14,9 @@ import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto interface MaterialSchemesProvider { fun getMaterialSchemesForUser(user: User?): MaterialSchemes fun getMaterialSchemesForCapabilities(capabilities: CapabilitiesDto?): MaterialSchemes - fun getMaterialSchemesForCurrentUser(): MaterialSchemes + + /** + * Schemes of the default account, for code without an account context of its own. + */ + fun getMaterialSchemesForDefaultUser(): MaterialSchemes } diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt index 3442203746..871482f4f0 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/MaterialSchemesProviderImpl.kt @@ -11,40 +11,34 @@ import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.models.json.capabilities.ThemingCapabilityDto +import com.nextcloud.talk.users.DefaultAccountProvider import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject internal class MaterialSchemesProviderImpl @Inject constructor( - private val userProvider: CurrentUserProviderOld, + private val defaultAccountProvider: DefaultAccountProvider, private val colorUtil: ColorUtil ) : MaterialSchemesProvider { - private val themeCache: ConcurrentHashMap = ConcurrentHashMap() + // Keyed by the theming capability instead of the server: accounts on the same server can have different colors + // (e.g. a personal primary color), and changed colors are used after the next capabilities sync. + private val themeCache: ConcurrentHashMap = ConcurrentHashMap() override fun getMaterialSchemesForUser(user: User?): MaterialSchemes { - val url: String = if (user?.baseUrl != null) { - user.baseUrl!! - } else { - FALLBACK_URL - } - - if (!themeCache.containsKey(url)) { - themeCache[url] = getMaterialSchemesForCapabilities(user?.capabilities) - } - - return themeCache[url]!! + val capabilities = user?.capabilities + // A copy, so a later change of the capability object cannot change the key of a cached entry. + val key = ThemeKey(capabilities?.themingCapability?.copy()) + return themeCache.getOrPut(key) { getMaterialSchemesForCapabilities(capabilities) } } - override fun getMaterialSchemesForCurrentUser(): MaterialSchemes = - getMaterialSchemesForUser(userProvider.currentUser.blockingGet()) + override fun getMaterialSchemesForDefaultUser(): MaterialSchemes = + getMaterialSchemesForUser(defaultAccountProvider.getDefaultUserBlocking()) override fun getMaterialSchemesForCapabilities(capabilities: CapabilitiesDto?): MaterialSchemes { val serverTheme = ServerThemeImpl(capabilities?.themingCapability, colorUtil) return MaterialSchemes.fromServerTheme(serverTheme) } - companion object { - const val FALLBACK_URL = "NULL" - } + private data class ThemeKey(val themingCapability: ThemingCapabilityDto?) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt index 296c34c294..eeaebadd80 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/ThemeModule.kt @@ -24,7 +24,7 @@ internal abstract class ThemeModule { companion object { @Provides - fun provideCurrentMaterialSchemes(schemesProvider: MaterialSchemesProvider): MaterialSchemes = - schemesProvider.getMaterialSchemesForCurrentUser() + fun provideDefaultMaterialSchemes(schemesProvider: MaterialSchemesProvider): MaterialSchemes = + schemesProvider.getMaterialSchemesForDefaultUser() } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt new file mode 100644 index 0000000000..263e530ee5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/ViewThemeUtilsFactory.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui.theme + +import com.nextcloud.android.common.ui.color.ColorUtil +import com.nextcloud.android.common.ui.theme.utils.AndroidViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.DialogViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.MaterialViewThemeUtils +import com.nextcloud.talk.data.user.model.User +import javax.inject.Inject + +/** + * Creates [ViewThemeUtils] themed with the server colors of a given account. + */ +class ViewThemeUtilsFactory @Inject constructor( + private val schemesProvider: MaterialSchemesProvider, + private val colorUtil: ColorUtil +) { + fun forUser(user: User): ViewThemeUtils { + val schemes = schemesProvider.getMaterialSchemesForUser(user) + val android = AndroidViewThemeUtils(schemes, colorUtil) + val material = MaterialViewThemeUtils(schemes, colorUtil) + val androidx = AndroidXViewThemeUtils(schemes, android) + val talk = TalkSpecificViewThemeUtils(schemes, androidx) + val dialog = DialogViewThemeUtils(schemes) + return ViewThemeUtils(schemes, android, material, androidx, talk, dialog) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt b/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt new file mode 100644 index 0000000000..3e7ca306c7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/users/DefaultAccountProvider.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.users + +import com.nextcloud.talk.data.user.model.User +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * Provides the default account, i.e. the last used one. + * + * Only for code without an account context of its own, like app entry points, share-to, the account switcher and the + * phone book integration. Screens, workers and receivers must use the account they were started for. + */ +class DefaultAccountProvider @Inject constructor(private val userManager: UserManager) { + + suspend fun getDefaultUser(): User? = userManager.getDefaultUser() + + /** + * Returns the default account without suspending. Only loads it from the database if it is not known yet. + */ + fun getDefaultUserBlocking(): User? = userManager.defaultUserFlow.value ?: runBlocking { getDefaultUser() } +} diff --git a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt index 5f1e454efd..1497c6d4d2 100644 --- a/app/src/main/java/com/nextcloud/talk/users/UserManager.kt +++ b/app/src/main/java/com/nextcloud/talk/users/UserManager.kt @@ -19,8 +19,10 @@ import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @Suppress("TooManyFunctions") @@ -32,10 +34,31 @@ class UserManager internal constructor(private val userRepository: UsersReposito suspend fun getUsersScheduledForDeletion(): List = userRepository.getUsersScheduledForDeletion() + suspend fun getUsersNotScheduledForDeletion(): List = userRepository.getUsersNotScheduledForDeletion() + + /** + * The default account, i.e. the last active user, or - if none is active - any user not scheduled for deletion, + * which is then set as active. + * + * Only for entry points that have no account context (app launch, share-to, deep links without account). + * Screens, workers and receivers must use the user they were started for, see [getUserWithId] and [userFlow]. + */ + suspend fun getDefaultUser(): User? = userRepository.getActiveUser() ?: getAnyUserAndSetAsActive() + /** - * The active user, or - if none is active - any user not scheduled for deletion, which is then set as active. + * Emits the user with the given internal id whenever its row changes, or null if it does not exist (anymore). */ - suspend fun getCurrentUser(): User? = userRepository.getActiveUser() ?: getAnyUserAndSetAsActive() + fun userFlow(id: Long): Flow = userRepository.getUserWithIdFlow(id).distinctUntilChanged() + + /** + * Ensures that at most one user is marked as active. If several are, the one with the highest id is kept, + * matching the user returned by [getDefaultUser]. + */ + suspend fun repairMultipleActiveUsers() { + if (userRepository.repairMultipleActiveUsers() > 0) { + Log.w(TAG, "Multiple active users found, kept the one with the highest id as active") + } + } /** * Backed by [activeUserStateFlow] rather than [UsersRepository.getActiveUserFlow] directly, so that @@ -46,8 +69,10 @@ class UserManager internal constructor(private val userRepository: UsersReposito * knows about it - e.g. launching a screen for the new user before its avatar/data had actually updated. * Room's own [UsersRepository.getActiveUserFlow] is still relied on underneath to seed this and to catch * any change to the `current` flag that doesn't go through [setUserAsActive]. + * + * Screens must not use this but the account they were started for, see [userFlow]. */ - val currentUserFlow: StateFlow + val defaultUserFlow: StateFlow get() = activeUserStateFlow private val activeUserStateFlow: MutableStateFlow by lazy { @@ -71,7 +96,10 @@ class UserManager internal constructor(private val userRepository: UsersReposito suspend fun getUserWithInternalId(id: Long): User? = userRepository.getUserWithIdNotScheduledForDeletion(id) suspend fun checkIfUserExists(username: String, server: String): Boolean = - userRepository.getUserWithUsernameAndServer(username, server) != null + getUserWithUsernameAndServer(username, server) != null + + suspend fun getUserWithUsernameAndServer(username: String, server: String): User? = + userRepository.getUserWithUsernameAndServer(username, server) /** * Don't ask @@ -99,25 +127,38 @@ class UserManager internal constructor(private val userRepository: UsersReposito } } + // The following updates only write the given fields, so they cannot reset fields that changed since the user + // was read, like the default account flag. Use them instead of saving a whole user that was read earlier. + suspend fun updateExternalSignalingServer(id: Long, externalSignalingServer: ExternalSignalingServer): Int { - val user = userRepository.getUserWithId(id) ?: throw NoSuchElementException() - user.externalSignalingServer = externalSignalingServer - return userRepository.updateUser(user) + val updated = userRepository.updateExternalSignalingServer(id, externalSignalingServer) + if (updated == 0) throw NoSuchElementException() + return updated } + suspend fun updateCapabilities(id: Long, capabilities: CapabilitiesDto?, serverVersion: ServerVersionDto?): Int = + userRepository.updateCapabilities(id, capabilities, serverVersion) + + suspend fun updateDisplayName(id: Long, displayName: String?): Int = + userRepository.updateDisplayName(id, displayName) + + suspend fun updateClientCertificate(id: Long, clientCertificate: String?): Int = + userRepository.updateClientCertificate(id, clientCertificate) + + suspend fun updateCredentials(id: Long, token: String?, clientCertificate: String?): Int = + userRepository.updateCredentials(id, token, clientCertificate) + suspend fun updateOrCreateUser(user: User): Int = when (user.id) { null -> userRepository.insertUser(user).toInt() else -> userRepository.updateUser(user) } - suspend fun saveUser(user: User): Int = userRepository.updateUser(user) - suspend fun setUserAsActive(user: User): Boolean { Log.d(TAG, "setUserAsActive:" + user.id!!) val success = userRepository.setUserAsActiveWithId(user.id!!) if (success) { - activeUserStateFlow.value = user + activeUserStateFlow.value = userRepository.getUserWithId(user.id!!) ?: user } return success } @@ -210,7 +251,6 @@ class UserManager internal constructor(private val userRepository: UsersReposito companion object { const val TAG = "UserManager" - private const val NO_ACTIVE_USER_ID = -1L } data class UserAttributes( diff --git a/app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt new file mode 100644 index 0000000000..4502ff4770 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/CredentialsCookieInterceptor.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Keeps server session cookies out of requests that authenticate with their own credentials. + * + * All accounts share one HTTP client with one cookie store, which keeps cookies per server only. A session cookie of + * one account would be sent along with the requests of another account on the same server, and the server would + * treat those requests as the first account. Requests with an Authorization header therefore neither send nor store + * cookies, so they are authenticated by their credentials alone. + * + * Must be added as a network interceptor, as OkHttp adds the cookies of the cookie jar and stores received cookies + * around the network interceptors. + */ +class CredentialsCookieInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (request.header(AUTHORIZATION) == null) { + return chain.proceed(request) + } + val response = chain.proceed(request.newBuilder().removeHeader(COOKIE).build()) + return response.newBuilder().removeHeader(SET_COOKIE).build() + } + + private companion object { + const val AUTHORIZATION = "Authorization" + const val COOKIE = "Cookie" + const val SET_COOKIE = "Set-Cookie" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index fbbf8f969e..f820cdcce5 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -47,6 +47,7 @@ import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX import com.nextcloud.talk.utils.Mimetype.VIDEO_QUICKTIME import com.nextcloud.talk.utils.Mimetype.VIDEO_WEBM import com.nextcloud.talk.utils.MimetypeUtils.isMarkdown +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID import java.util.concurrent.ExecutionException @@ -117,7 +118,9 @@ class FileViewerUtils(private val context: Context, private val user: User) { actorDisplayName = message.actorDisplayName.orEmpty(), timestamp = message.timestamp ) - context.startActivity(MediaViewerActivity.newIntent(context, roomToken, listOf(item), item.messageId)) + context.startActivity( + MediaViewerActivity.newIntent(context, user.id!!, roomToken, listOf(item), item.messageId) + ) } fun openFile( @@ -267,8 +270,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { fullScreenTextViewerIntent.putExtra("IS_MARKDOWN", isMarkdown(mimetype)) fullScreenTextViewerIntent.putExtra("FILE_ID", fileId) fullScreenTextViewerIntent.putExtra("LINK", link) - fullScreenTextViewerIntent.putExtra("USERNAME", user.username) - fullScreenTextViewerIntent.putExtra("BASE_URL", user.baseUrl) + fullScreenTextViewerIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id) context.startActivity(fullScreenTextViewerIntent) } @@ -319,8 +321,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } val data: Data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, user.baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, user.userId) + .putLong(BundleKeys.KEY_INTERNAL_USER_ID, user.id!!) .putString( DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, CapabilitiesUtil.getAttachmentFolder(user.capabilities!!.spreedCapability!!) diff --git a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt index 3d148e2f39..a831767198 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt @@ -82,6 +82,7 @@ class PickImage(private val activity: Activity, private var currentUser: User?) val avatarIntent = Intent(activity, RemoteFileBrowserActivity::class.java) avatarIntent.putExtras(bundle) + currentUser?.id?.let { avatarIntent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, it) } startSelectRemoteFilesIntentForResult.launch(avatarIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt index 68b273268e..d7ab1301f1 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/RemoteWipeInterceptor.kt @@ -60,7 +60,7 @@ class RemoteWipeInterceptor( return response } - val candidate = resolveWipeCandidate(chain.request().url.toString()) ?: return response + val candidate = resolveWipeCandidate(chain.request()) ?: return response if (!handledUserIds.add(candidate.userId)) { Log.d(TAG, "User ${candidate.userId} was already handled, ignoring") return response @@ -72,27 +72,36 @@ class RemoteWipeInterceptor( return response } - private fun resolveWipeCandidate(requestUrl: String): WipeCandidate? { - val user = runBlocking { userManager.getUsers() } - .firstOrNull { it.baseUrl != null && requestUrl.startsWith(it.baseUrl!!) } - if (user == null) { - Log.d(TAG, "No known user matches base URL of $requestUrl, ignoring") - return null + /** + * The account whose credentials the server rejected: the one with exactly the credentials of [request], on the + * server of its URL. A 401 for a request without credentials, or with credentials of no stored account, says + * nothing about an account, e.g. a request that relied on a session cookie, and is ignored. Several accounts can + * be on the same server, so the server alone does not identify the account. + */ + private fun resolveWipeCandidate(request: Request): WipeCandidate? { + val user = findUserWithCredentialsOf(request) + val token = user?.token + val userId = user?.id + + return if (user != null && token != null && userId != null) { + WipeCandidate(user, token, userId) + } else { + Log.d(TAG, "401 for a request without the credentials of a known account, ignoring: ${request.url}") + null } + } - val token = user.token - if (token == null) { - Log.d(TAG, "User ${user.id} has no token, ignoring") - return null - } + /** + * The stored account on the server of [request] whose credentials the request carries, if any. + */ + private fun findUserWithCredentialsOf(request: Request): User? { + val credentials = request.header(AUTHORIZATION) ?: return null + val requestUrl = request.url.toString() - val userId = user.id - if (userId == null) { - Log.d(TAG, "User has no id, ignoring") - return null + return runBlocking { userManager.getUsers() }.firstOrNull { user -> + val isOnServerOfRequest = user.baseUrl?.let { UriUtils.isOnServer(requestUrl, it) } == true + isOnServerOfRequest && ApiUtils.getCredentials(user.username, user.token) == credentials } - - return WipeCandidate(user, token, userId) } private fun isWipeRequestedByServer(candidate: WipeCandidate): Boolean { @@ -155,6 +164,7 @@ class RemoteWipeInterceptor( companion object { private const val TAG = "RemoteWipeInterceptor" private const val HTTP_UNAUTHORIZED = 401 + private const val AUTHORIZATION = "Authorization" private const val WIPE_PATH = "wipe" } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt index 955d836259..c56f8d4b09 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt @@ -8,11 +8,29 @@ package com.nextcloud.talk.utils import androidx.core.net.toUri +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull class UriUtils { companion object { fun hasHttpProtocolPrefixed(uri: String): Boolean = uri.startsWith("http://") || uri.startsWith("https://") + /** + * Whether [url] points to the server at [baseUrl]: same scheme, host and port, and within its path. Unlike a + * prefix check, this rejects e.g. `https://cloud.example.com.attacker.test` for `https://cloud.example.com`, + * so it can decide whether to send the account's credentials. + */ + fun isOnServer(url: String, baseUrl: String): Boolean { + val target = url.toHttpUrlOrNull() + val server = baseUrl.toHttpUrlOrNull() + val serverPath = server?.encodedPath?.trimEnd('/').orEmpty() + return target != null && + server != null && + target.scheme == server.scheme && + target.host == server.host && + target.port == server.port && + (target.encodedPath == serverPath || target.encodedPath.startsWith("$serverPath/")) + } + fun extractInstanceInternalFileFileId(url: String): String { // https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41 return url.toUri().getQueryParameter("fileid").toString() diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt deleted file mode 100644 index 965d2387a0..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import kotlinx.coroutines.flow.Flow - -interface CurrentUserProvider { - val currentUserFlow: Flow - suspend fun getCurrentUser(timeout: Long = 5000L): Result -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt deleted file mode 100644 index abc2ee7820..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.users.UserManager -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeoutOrNull -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class CurrentUserProviderImpl @Inject constructor(private val userManager: UserManager) : CurrentUserProvider { - - // only emit non-null users - override val currentUserFlow: Flow = userManager.currentUserFlow.filterNotNull() - - // function for safe one-shot access - override suspend fun getCurrentUser(timeout: Long): Result { - val user = withTimeoutOrNull(timeout) { - currentUserFlow.first() - } - - return if (user != null) { - Result.success(user) - } else { - Result.failure(IllegalStateException("No current user available")) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt deleted file mode 100644 index 1663cad1a8..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOld.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import io.reactivex.Maybe - -@Deprecated("Use CurrentUserProvider instead") -interface CurrentUserProviderOld { - val currentUser: Maybe -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt deleted file mode 100644 index 7d525b7355..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderOldImpl.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.database.user - -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.users.UserManager -import io.reactivex.Maybe -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Listens to changes in the database and provides the current user without needing to query the database everytime. - */ -@Deprecated("Use CurrentUserProvider instead") -@Singleton -class CurrentUserProviderOldImpl @Inject constructor(private val userManager: UserManager) : CurrentUserProviderOld { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private var _currentUser: User? = null - - // synchronized to avoid multiple observers initialized from different threads - @get:Synchronized - @set:Synchronized - private var currentUserObserver: Job? = null - - @Deprecated("Use currentUserProvider instead") - override val currentUser: Maybe - get() { - if (_currentUser == null) { - // immediately get a result synchronously - _currentUser = runBlocking { userManager.getCurrentUser() } - if (currentUserObserver == null) { - currentUserObserver = scope.launch { - userManager.currentUserFlow.filterNotNull().collect { - _currentUser = it - } - } - } - } - return _currentUser?.let { Maybe.just(it) } ?: Maybe.empty() - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt index 07036cc858..4ff6b378e9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt @@ -11,7 +11,6 @@ package com.nextcloud.talk.utils.database.user import com.nextcloud.talk.dagger.modules.DatabaseModule import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.users.UserManager -import dagger.Binds import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -19,14 +18,6 @@ import javax.inject.Singleton @Module(includes = [DatabaseModule::class]) abstract class UserModule { - @Binds - abstract fun bindCurrentUserProviderOld( - currentUserProviderOldImpl: CurrentUserProviderOldImpl - ): CurrentUserProviderOld - - @Binds - abstract fun bindCurrentUserProvider(currentUserProviderImpl: CurrentUserProviderImpl): CurrentUserProvider - companion object { @Provides @Singleton diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index 9b7da27ae0..729c2f51ef 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -44,6 +44,7 @@ import com.nextcloud.talk.data.network.NetworkMonitorImpl import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepositoryImpl import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.reactions.ReactionsRepositoryImpl @@ -54,10 +55,8 @@ import com.nextcloud.talk.threadsoverview.data.ThreadsRepositoryImpl import com.nextcloud.talk.ui.theme.MaterialSchemesProviderImpl import com.nextcloud.talk.ui.theme.TalkSpecificViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.DefaultAccountProvider import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.preferences.AppPreferencesImpl @@ -66,6 +65,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory @@ -95,14 +95,17 @@ class ComposePreviewUtils private constructor(context: Context) { val userManager: UserManager get() = UserManager(userRepository) - val userProvider: CurrentUserProviderOld - get() = CurrentUserProviderOldImpl(userManager) + val defaultAccountProvider: DefaultAccountProvider + get() = DefaultAccountProvider(userManager) + + val currentUser: User + get() = runBlocking { userManager.getDefaultUser() }!! val colorUtil: ColorUtil get() = ColorUtil(mContext) val materialScheme: MaterialSchemes - get() = MaterialSchemesProviderImpl(userProvider, colorUtil).getMaterialSchemesForCurrentUser() + get() = MaterialSchemesProviderImpl(defaultAccountProvider, colorUtil).getMaterialSchemesForDefaultUser() val viewThemeUtils: ViewThemeUtils get() { @@ -209,9 +212,6 @@ class ComposePreviewUtils private constructor(context: Context) { val audioFocusRequestManager: AudioFocusRequestManager get() = AudioFocusRequestManager(mContext) - val currentUserProvider: CurrentUserProviderImpl - get() = CurrentUserProviderImpl(userManager) - object TestLogger : Logger { override fun d(tag: String, message: String) = Unit override fun d(tag: String, message: String, t: Throwable) = Unit @@ -233,8 +233,9 @@ class ComposePreviewUtils private constructor(context: Context) { unifiedSearchRepository = unifiedSearchRepository, mediaRecorderManager = mediaRecorderManager, audioFocusRequestManager = audioFocusRequestManager, - currentUserProvider = currentUserProvider, + userManager = userManager, appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + initialUser = currentUser, chatRoomToken = "", conversationThreadId = null, logger = TestLogger @@ -244,7 +245,7 @@ class ComposePreviewUtils private constructor(context: Context) { get() = ContactsRepositoryImpl(ncApiCoroutines) val contactsViewModel: ContactsViewModel - get() = ContactsViewModel(contactsRepository, currentUserProvider, TestLogger) + get() = ContactsViewModel(contactsRepository, TestLogger, currentUser) val conversationCreationViewModel: ConversationCreationViewModel get() = ConversationCreationRepositoryImpl(ncApiCoroutines).let { repository -> @@ -252,7 +253,7 @@ class ComposePreviewUtils private constructor(context: Context) { repository, ConversationCreator(repository, TestLogger), PasswordPolicyRepositoryImpl(ncApiCoroutines), - currentUserProvider + currentUser ) } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 1dc27b7788..1c00bfb971 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -14,7 +14,12 @@ import com.nextcloud.talk.data.database.model.ChatBlockEntity import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.database.model.ConversationEntity import com.nextcloud.talk.data.user.UsersDao +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate import com.nextcloud.talk.models.json.push.PushConfigurationState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -205,6 +210,10 @@ class DummyUserDaoImpl : UsersDao { override suspend fun getUserWithId(id: Long): UserEntity? = dummyUsers.find { it.id == id } + override fun getUserWithIdFlow(id: Long): Flow = flowOf(dummyUsers.find { it.id == id }) + + override suspend fun repairMultipleActiveUsers(): Int = 0 + override suspend fun getUserWithIdNotScheduledForDeletion(id: Long): UserEntity? = dummyUsers.find { it.id == id && !it.scheduledForDeletion } @@ -237,6 +246,30 @@ class DummyUserDaoImpl : UsersDao { 0 } } + + override suspend fun updateCapabilities(update: UserCapabilitiesUpdate): Int = + updateDummyUser(update.id) { + it.copy(capabilities = update.capabilities, serverVersion = update.serverVersion) + } + + override suspend fun updateExternalSignalingServer(update: UserExternalSignalingServerUpdate): Int = + updateDummyUser(update.id) { it.copy(externalSignalingServer = update.externalSignalingServer) } + + override suspend fun updateDisplayName(update: UserDisplayNameUpdate): Int = + updateDummyUser(update.id) { it.copy(displayName = update.displayName) } + + override suspend fun updateClientCertificate(update: UserClientCertificateUpdate): Int = + updateDummyUser(update.id) { it.copy(clientCertificate = update.clientCertificate) } + + override suspend fun updateCredentials(update: UserCredentialsUpdate): Int = + updateDummyUser(update.id) { it.copy(token = update.token, clientCertificate = update.clientCertificate) } + + private fun updateDummyUser(id: Long, update: (UserEntity) -> UserEntity): Int { + val index = dummyUsers.indexOfFirst { it.id == id } + if (index == -1) return 0 + dummyUsers[index] = update(dummyUsers[index]) + return 1 + } } class DummyConversationDaoImpl : ConversationsDao { diff --git a/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt b/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt deleted file mode 100644 index 5097ff8465..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils.rx - -import androidx.appcompat.widget.SearchView -import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject - -class SearchViewObservable { - - companion object { - @JvmStatic - fun observeSearchView(searchView: SearchView): Observable { - val subject: PublishSubject = PublishSubject.create() - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - searchView.clearFocus() - subject.onComplete() - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - subject.onNext(newText) - return true - } - }) - return subject - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java b/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java index 515bae0633..2bb5895af9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java +++ b/app/src/main/java/com/nextcloud/talk/utils/singletons/ApplicationWideCurrentRoomHolder.java @@ -41,6 +41,14 @@ public String getCurrentRoomToken() { return currentRoomToken; } + /** + * Whether the current room is the room with the given token of the account with the given internal id. The token + * alone is not enough: accounts on the same server share the tokens of their common conversations. + */ + public boolean isCurrentRoom(String roomToken, Long userId) { + return currentRoomToken.equals(roomToken) && userInRoom.getId() != null && userInRoom.getId().equals(userId); + } + public void setCurrentRoomToken(String currentRoomToken) { this.currentRoomToken = currentRoomToken; } diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt b/app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt new file mode 100644 index 0000000000..c1c143ef7a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelector.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils.ssl + +import com.nextcloud.talk.data.user.model.User +import java.net.URI +import java.net.URISyntaxException + +/** + * Chooses the client certificate of the account a TLS connection is for. + * + * The TLS handshake only knows the server, not the account of the request. So the certificate is chosen by the + * server: the one of the accounts on it. Only if accounts on the same server use different certificates, the + * default account decides, as the connection may then be reused by all of them. + */ +object ClientCertificateSelector { + + /** + * Returns the alias of the client certificate for a connection to [host], or null if no account on it uses one. + * Without a [host], the certificate of [defaultUser] is used. + */ + @JvmStatic + fun selectAlias(host: String?, users: List, defaultUser: User?): String? { + val usersOnHost = users.filter { host != null && hostOf(it).equals(host, ignoreCase = true) } + val aliases = usersOnHost.mapNotNull { aliasOf(it) }.toSet() + return when { + host == null -> aliasOf(defaultUser) + aliases.size <= 1 -> aliases.firstOrNull() + else -> defaultUser?.takeIf { default -> usersOnHost.any { it.id == default.id } }?.let { aliasOf(it) } + } + } + + private fun aliasOf(user: User?): String? = user?.clientCertificate?.takeIf { it.isNotEmpty() } + + private fun hostOf(user: User): String? = + try { + user.baseUrl?.let { URI(it).host } + } catch (e: URISyntaxException) { + null + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java b/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java index cf867f74ba..885e3cd9a6 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/KeyManager.java @@ -27,6 +27,8 @@ import java.util.List; import java.util.Set; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; import javax.net.ssl.X509KeyManager; import kotlin.coroutines.EmptyCoroutineContext; import kotlinx.coroutines.BuildersKt; @@ -49,20 +51,28 @@ public KeyManager(X509KeyManager keyManager, UserManager userManager, AppPrefere context = NextcloudTalkApplication.Companion.getSharedApplication().getApplicationContext(); } + /** + * Chooses the certificate of the accounts on the server of the connection, see {@link ClientCertificateSelector}. The + * handshake doesn't know the account of the request, so the default account alone would offer the wrong + * certificate for the requests of other accounts. + */ @Override public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) { String alias; - User currentUser = null; + List users = new ArrayList<>(); + User defaultUser = null; try { - currentUser = BuildersKt.runBlocking( + users = BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, - (scope, continuation) -> userManager.getCurrentUser(continuation)); + (scope, continuation) -> userManager.getUsers(continuation)); + defaultUser = BuildersKt.runBlocking( + EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> userManager.getDefaultUser(continuation)); } catch (InterruptedException e) { - Log.e(TAG, "Interrupted while getting the current user: " + e.getLocalizedMessage()); + Log.e(TAG, "Interrupted while getting the users: " + e.getLocalizedMessage()); Thread.currentThread().interrupt(); } - if ((currentUser != null && - !TextUtils.isEmpty(alias = currentUser.getClientCertificate())) || + if (!TextUtils.isEmpty(alias = ClientCertificateSelector.selectAlias(getPeerHost(socket), users, defaultUser)) || !TextUtils.isEmpty(alias = appPreferences.getTemporaryClientCertAlias()) && new ArrayList<>(Arrays.asList(getClientAliases())).contains(alias)) { return alias; @@ -71,6 +81,20 @@ public String chooseClientAlias(String[] strings, Principal[] principals, Socket return null; } + @Nullable + private static String getPeerHost(@Nullable Socket socket) { + if (socket instanceof SSLSocket) { + SSLSession session = ((SSLSocket) socket).getHandshakeSession(); + if (session != null && session.getPeerHost() != null) { + return session.getPeerHost(); + } + } + if (socket != null && socket.getInetAddress() != null) { + return socket.getInetAddress().getHostName(); + } + return null; + } + @Override public String chooseServerAlias(String s, Principal[] principals, Socket socket) { return null; diff --git a/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt b/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt index 60f879e505..c61b5c1d78 100644 --- a/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt @@ -15,19 +15,16 @@ import com.nextcloud.talk.models.domain.StartCallRecordingModel import com.nextcloud.talk.models.domain.StopCallRecordingModel import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject -class CallRecordingViewModel @Inject constructor( - private val repository: CallRecordingRepository, - private val currentUserProvider: CurrentUserProviderOld -) : ViewModel() { +class CallRecordingViewModel @Inject constructor(private val repository: CallRecordingRepository) : ViewModel() { lateinit var roomToken: String + private lateinit var currentUser: User sealed interface ViewState open class RecordingStartedState(val hasVideo: Boolean, val showStartedInfo: Boolean) : ViewState @@ -44,8 +41,8 @@ class CallRecordingViewModel @Inject constructor( private var disposable: Disposable? = null - private var currentUser: User = currentUserProvider.currentUser.blockingGet() - val credentials: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! + private val credentials: String + get() = ApiUtils.getCredentials(currentUser.username, currentUser.token)!! fun clickRecordButton() { when (viewState.value) { @@ -117,7 +114,8 @@ class CallRecordingViewModel @Inject constructor( disposable?.dispose() } - fun setData(roomToken: String) { + fun setData(user: User, roomToken: String) { + this.currentUser = user this.roomToken = roomToken } diff --git a/app/src/main/res/layout/activity_message_search.xml b/app/src/main/res/layout/activity_message_search.xml deleted file mode 100644 index e077575275..0000000000 --- a/app/src/main/res/layout/activity_message_search.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/rv_item_load_more.xml b/app/src/main/res/layout/rv_item_load_more.xml deleted file mode 100644 index 7e05fe57ba..0000000000 --- a/app/src/main/res/layout/rv_item_load_more.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/rv_item_search_message.xml b/app/src/main/res/layout/rv_item_search_message.xml deleted file mode 100644 index 020d276c5c..0000000000 --- a/app/src/main/res/layout/rv_item_search_message.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/menu_search.xml deleted file mode 100644 index 970712def4..0000000000 --- a/app/src/main/res/menu/menu_search.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index ca9a240d29..ba77a21a5e 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -21,7 +21,6 @@ 8dp - 16dp 40dp 96dp 52dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6022720eb5..8ab569cb88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ How to translate with transifex: Set Dismiss Sorry, something went wrong! + You logged in with a different account. Log in with the account you want to reauthorize. Create Unknown Disabled @@ -885,8 +886,6 @@ How to translate with transifex: Messages Load more results Search … - Start typing to search … - No search results Previous search result Next search result No more messages found diff --git a/app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt new file mode 100644 index 0000000000..0d089501bd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/account/viewmodels/BrowserLoginActivityViewModelTest.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.account.viewmodels + +import com.nextcloud.talk.account.data.LoginRepository +import com.nextcloud.talk.account.data.model.LoginResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +@OptIn(ExperimentalCoroutinesApi::class) +class BrowserLoginActivityViewModelTest { + + private val repository: LoginRepository = mock() + private lateinit var viewModel: BrowserLoginActivityViewModel + + private val loginResponse = LoginResponse(token = "token", pollUrl = "https://example.com/poll", loginUrl = URL) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + viewModel = BrowserLoginActivityViewModel(repository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `starting the login again continues the started login`() { + wheneverBlocking { repository.startLoginFlow(any(), any(), anyOrNull()) }.thenReturn(loginResponse) + + viewModel.startWebBrowserLogin(BASE_URL) + viewModel.startWebBrowserLogin(BASE_URL) + + verifyBlocking(repository, times(1)) { startLoginFlow(any(), any(), anyOrNull()) } + assertEquals( + BrowserLoginActivityViewModel.InitialLoginViewState.InitialLoginRequestSuccess(URL), + viewModel.initialLoginRequestState.value + ) + } + + @Test + fun `a QR code login is not started twice either`() { + wheneverBlocking { repository.startOTPLoginFlow(any(), any(), anyOrNull()) }.thenReturn(null) + + viewModel.loginWithOTPQR("qr") + viewModel.loginWithOTPQR("qr") + + verifyBlocking(repository, times(1)) { startOTPLoginFlow(any(), any(), anyOrNull()) } + } + + companion object { + private const val BASE_URL = "https://example.com" + private const val URL = "https://example.com/login" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt index 55c3b2f1bd..76f9799ca7 100644 --- a/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/contacts/ContactsViewModelTest.kt @@ -16,8 +16,6 @@ import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProvider -import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl import com.nextcloud.talk.utils.preview.DummyUserDaoImpl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -33,6 +31,7 @@ import org.junit.Test import org.mockito.kotlin.mock @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("TooManyFunctions") class ContactsViewModelTest { private lateinit var viewModel: ContactsViewModel private val repository: ContactsRepository = FakeRepositorySuccess() @@ -49,19 +48,11 @@ class ContactsViewModelTest { val userManager: UserManager get() = UserManager(userRepository) - val userProvider: CurrentUserProvider - get() = CurrentUserProviderImpl(userManager) - private var viewModelCount = 0 private fun createViewModel(repo: ContactsRepository): ContactsViewModel { - val provider = userProvider - // CurrentUserProviderImpl feeds its currentUserFlow from a real Dispatchers.IO - // scope, independent of the test dispatcher's virtual time. Waiting for it here - // (real blocking wait, since this isn't inside runTest) ensures the value is - // already cached by the time the ViewModel's viewModelScope collects it. - runBlocking { provider.getCurrentUser() } - return ContactsViewModel(repo, provider, mock()).also { + val user = runBlocking { userManager.getDefaultUser() }!! + return ContactsViewModel(repo, mock(), user).also { viewModelStore.put("contactsViewModel${viewModelCount++}", it) } } @@ -136,6 +127,14 @@ class ContactsViewModelTest { assert(successState.conversation == FakeItem.roomOverall.ocs!!.data) } + @Test + fun `a handled room state is cleared`() = + runTest { + viewModel.createRoom("1", "users", "s@gmail.com", null) + viewModel.clearRoomState() + assert(viewModel.roomViewState.value is ContactsViewModel.RoomUiState.None) + } + @Test fun `test failure room state`() = runTest { diff --git a/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt index 31d7439ba5..c1be03efce 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModelTest.kt @@ -16,11 +16,8 @@ import com.nextcloud.talk.models.json.capabilities.PasswordApiDto import com.nextcloud.talk.models.json.capabilities.PasswordPolicyDto import com.nextcloud.talk.passwordpolicy.FakePasswordPolicyRepository import com.nextcloud.talk.utils.SpreedFeatures -import com.nextcloud.talk.utils.database.user.CurrentUserProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -35,28 +32,21 @@ import org.junit.Test import org.mockito.kotlin.mock /** - * What the creation screen's ViewModel does before and after the current user is known. + * What the creation screen's ViewModel does depending on the capabilities of its user. */ @OptIn(ExperimentalCoroutinesApi::class) class ConversationCreationViewModelTest { - private val users = MutableSharedFlow(replay = 1) private val repository = FakeConversationCreationRepository() - private val userProvider = object : CurrentUserProvider { - override val currentUserFlow: Flow = users - override suspend fun getCurrentUser(timeout: Long): Result = - Result.failure(UnsupportedOperationException()) - } - private val passwordPolicyRepository = FakePasswordPolicyRepository() - private fun viewModel() = + private fun viewModel(user: User) = ConversationCreationViewModel( repository, ConversationCreator(repository, mock()), passwordPolicyRepository, - userProvider + user ) private fun user(vararg spreedFeatures: SpreedFeatures, passwordEnforced: Boolean = false) = @@ -89,32 +79,9 @@ class ConversationCreationViewModelTest { } @Test - fun `nothing is asked of the server while the current user is unknown`() = - runTest { - val model = viewModel() - - assertNull(model.currentUser.value) - assertEquals(0, repository.presetCalls) - assertTrue(model.isLoadingPresets) - } - - @Test - fun `creating is refused while the current user is unknown`() = + fun `the presets are requested when the server supports them`() = runTest { - val model = viewModel() - - model.createRoomAndAddParticipants() - - assertNull(repository.bodyRequest) - assertNull(repository.formRequest) - } - - @Test - fun `the presets are requested once the user arrives`() = - runTest { - val model = viewModel() - - users.emit(user(SpreedFeatures.CONVERSATION_PRESETS)) + val model = viewModel(user(SpreedFeatures.CONVERSATION_PRESETS)) assertEquals(1, repository.presetCalls) assertTrue(model.presets.value is PresetsUiState.Success) @@ -125,8 +92,7 @@ class ConversationCreationViewModelTest { fun `opening a conversation to guests generates the password the server enforces`() = runTest { passwordPolicyRepository.generatedPassword = "generated" - val model = viewModel() - users.emit(user(passwordEnforced = true)) + val model = viewModel(user(passwordEnforced = true)) model.allowGuests(true) @@ -137,8 +103,7 @@ class ConversationCreationViewModelTest { fun `a password the user typed is left alone`() = runTest { passwordPolicyRepository.generatedPassword = "generated" - val model = viewModel() - users.emit(user(passwordEnforced = true)) + val model = viewModel(user(passwordEnforced = true)) model.updatePassword("hunter2") model.allowGuests(true) @@ -150,8 +115,7 @@ class ConversationCreationViewModelTest { fun `no password is generated where the server does not enforce one`() = runTest { passwordPolicyRepository.generatedPassword = "generated" - val model = viewModel() - users.emit(user()) + val model = viewModel(user()) model.allowGuests(true) @@ -162,9 +126,7 @@ class ConversationCreationViewModelTest { @Test fun `a server without the capability is never asked for presets`() = runTest { - val model = viewModel() - - users.emit(user()) + val model = viewModel(user()) assertEquals(0, repository.presetCalls) assertFalse(model.showPresetSelection) diff --git a/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt b/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt index 99fc651fae..c3a1254e98 100644 --- a/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt +++ b/app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt @@ -379,7 +379,7 @@ class ConversationListFreshnessIntegrationTest { runBlocking { val emissions = mutableListOf>() val collector = launch(Dispatchers.IO) { - repository.roomListFlow.collect { emissions.add(it) } + repository.roomListFlow(user.id!!).collect { emissions.add(it) } } repository.getRooms(user).join() @@ -392,6 +392,21 @@ class ConversationListFreshnessIntegrationTest { } } + @Test + fun `room list flow only emits the conversations of the requested account`() { + val repository = repository() + + runBlocking { + db.usersDao().saveUser( + UserEntity(id = OTHER_ACCOUNT_ID, userId = "other", username = "other", baseUrl = BASE_URL) + ) + seedConversation(lastActivity = 10, lastReadMessage = 1, unreadMessages = 0) + + assertEquals(1, repository.roomListFlow(ACCOUNT_ID).first().size) + assertTrue(repository.roomListFlow(OTHER_ACCOUNT_ID).first().isEmpty()) + } + } + private fun repository(): OfflineFirstConversationsRepository = OfflineFirstConversationsRepository( db.conversationsDao(), @@ -493,6 +508,7 @@ class ConversationListFreshnessIntegrationTest { companion object { private const val ACCOUNT_ID = 1L + private const val OTHER_ACCOUNT_ID = 2L private const val BASE_URL = "https://server.example.com" private const val ROOM_TOKEN = "room1" private const val INTERNAL_CONVERSATION_ID = "$ACCOUNT_ID@$ROOM_TOKEN" diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt new file mode 100644 index 0000000000..9532a5b833 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoPartialUpdateTest.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user + +import android.app.Application +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.talk.data.source.local.TalkDatabase +import com.nextcloud.talk.data.user.model.UserCapabilitiesUpdate +import com.nextcloud.talk.data.user.model.UserClientCertificateUpdate +import com.nextcloud.talk.data.user.model.UserCredentialsUpdate +import com.nextcloud.talk.data.user.model.UserDisplayNameUpdate +import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.data.user.model.UserExternalSignalingServerUpdate +import com.nextcloud.talk.models.ExternalSignalingServer +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.ServerVersionDto +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The partial updates must only write their own columns, so a user that was read before the default account + * changed cannot reset it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UsersDaoPartialUpdateTest { + + private lateinit var db: TalkDatabase + private lateinit var dao: UsersDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, TalkDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.usersDao() + runBlocking { + dao.saveUser(UserEntity(id = USER_A, username = "a", baseUrl = BASE_URL, token = "tokenA", current = true)) + dao.saveUser(UserEntity(id = USER_B, username = "b", baseUrl = BASE_URL, token = "tokenB")) + } + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `partial updates of a previously active user keep the new default account`() = + runBlocking { + dao.setUserAsActiveWithId(USER_B) + + dao.updateCapabilities(UserCapabilitiesUpdate(USER_A, CapabilitiesDto(), ServerVersionDto(major = 30))) + dao.updateExternalSignalingServer( + UserExternalSignalingServerUpdate(USER_A, ExternalSignalingServer(externalSignalingServer = "wss://s")) + ) + dao.updateDisplayName(UserDisplayNameUpdate(USER_A, "Alice")) + dao.updateClientCertificate(UserClientCertificateUpdate(USER_A, "alias")) + dao.updateCredentials(UserCredentialsUpdate(USER_A, "newToken", "alias")) + + val userA = dao.getUserWithId(USER_A)!! + assertFalse(userA.current) + assertTrue(dao.getUserWithId(USER_B)!!.current) + assertEquals(USER_B, dao.getActiveUser()!!.id) + assertEquals(30, userA.serverVersion?.major) + assertEquals("wss://s", userA.externalSignalingServer?.externalSignalingServer) + assertEquals("Alice", userA.displayName) + assertEquals("newToken", userA.token) + assertEquals("alias", userA.clientCertificate) + } + + companion object { + private const val USER_A = 1L + private const val USER_B = 2L + private const val BASE_URL = "https://example.com" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt new file mode 100644 index 0000000000..8a419140c1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersDaoRepairTest.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user + +import android.app.Application +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.talk.data.source.local.TalkDatabase +import com.nextcloud.talk.data.user.model.UserEntity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UsersDaoRepairTest { + + private lateinit var db: TalkDatabase + private lateinit var dao: UsersDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, TalkDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.usersDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `several active users are reduced to the one with the highest id`() = + runBlocking { + saveUsers(activeIds = setOf(1L, 3L)) + + val updated = dao.repairMultipleActiveUsers() + + assertEquals(USER_IDS.size, updated) + assertEquals(listOf(3L), activeIds()) + } + + @Test + fun `a single active user is left unchanged`() = + runBlocking { + saveUsers(activeIds = setOf(1L)) + + val updated = dao.repairMultipleActiveUsers() + + assertEquals(0, updated) + assertEquals(listOf(1L), activeIds()) + } + + private suspend fun saveUsers(activeIds: Set) { + USER_IDS.forEach { id -> + dao.saveUser(UserEntity(id = id, username = "user$id", baseUrl = BASE_URL, current = id in activeIds)) + } + } + + private suspend fun activeIds(): List = dao.getUsers().filter { it.current }.map { it.id } + + companion object { + private val USER_IDS = listOf(1L, 2L, 3L) + private const val BASE_URL = "https://example.com" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt b/app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt new file mode 100644 index 0000000000..634b628927 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/data/user/UsersRepositoryImplTest.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.data.user + +import com.nextcloud.talk.data.user.model.UserEntity +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +class UsersRepositoryImplTest { + + private val usersDao: UsersDao = mock() + private val repository = UsersRepositoryImpl(usersDao) + + @Test + fun `getActiveUser only reads the database`() = + runTest { + val entity = UserEntity(id = 1L, username = "userA", baseUrl = "https://example.com", current = true) + wheneverBlocking { usersDao.getActiveUser() }.thenReturn(entity) + + val result = repository.getActiveUser() + + assertEquals(1L, result?.id) + verifyBlocking(usersDao, never()) { setUserAsActiveWithId(any()) } + } +} diff --git a/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt index 5b1fd29782..5610b0d4cc 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt +++ b/app/src/test/java/com/nextcloud/talk/jobs/CapabilitiesFetcherTest.kt @@ -22,6 +22,8 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -51,7 +53,7 @@ class CapabilitiesFetcherTest { val testUser = user() val capabilities = CapabilitiesDto() val serverVersion = ServerVersionDto(major = 30) - wheneverBlocking { userManager.updateOrCreateUser(testUser) }.thenReturn(1) + wheneverBlocking { userManager.updateCapabilities(USER_ID, capabilities, serverVersion) }.thenReturn(1) val result = fetcher.updateUser(overall(capabilities, serverVersion), testUser) @@ -65,7 +67,7 @@ class CapabilitiesFetcherTest { fun `returns false and posts failure when no row was updated`() = runTest { val testUser = user() - wheneverBlocking { userManager.updateOrCreateUser(testUser) }.thenReturn(0) + wheneverBlocking { userManager.updateCapabilities(any(), anyOrNull(), anyOrNull()) }.thenReturn(0) val result = fetcher.updateUser(overall(CapabilitiesDto()), testUser) @@ -77,7 +79,8 @@ class CapabilitiesFetcherTest { fun `returns false and posts failure when persisting the user throws`() = runTest { val testUser = user() - wheneverBlocking { userManager.updateOrCreateUser(testUser) }.thenThrow(RuntimeException("db error")) + wheneverBlocking { userManager.updateCapabilities(any(), anyOrNull(), anyOrNull()) } + .thenThrow(RuntimeException("db error")) val result = fetcher.updateUser(overall(CapabilitiesDto()), testUser) @@ -94,7 +97,7 @@ class CapabilitiesFetcherTest { assertFalse(result) verify(eventBus).post(EventStatus(USER_ID, EventStatus.EventType.CAPABILITIES_FETCH, false)) - verifyBlocking(userManager, never()) { updateOrCreateUser(testUser) } + verifyBlocking(userManager, never()) { updateCapabilities(any(), anyOrNull(), anyOrNull()) } } @Test @@ -106,7 +109,7 @@ class CapabilitiesFetcherTest { assertFalse(result) verify(eventBus).post(EventStatus(USER_ID, EventStatus.EventType.CAPABILITIES_FETCH, false)) - verifyBlocking(userManager, never()) { updateOrCreateUser(testUser) } + verifyBlocking(userManager, never()) { updateCapabilities(any(), anyOrNull(), anyOrNull()) } } @Test @@ -118,7 +121,7 @@ class CapabilitiesFetcherTest { assertFalse(result) verify(eventBus).post(EventStatus(USER_ID, EventStatus.EventType.CAPABILITIES_FETCH, false)) - verifyBlocking(userManager, never()) { updateOrCreateUser(testUser) } + verifyBlocking(userManager, never()) { updateCapabilities(any(), anyOrNull(), anyOrNull()) } } companion object { diff --git a/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt index 571d08110c..7bb71d483d 100644 --- a/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/location/viewmodels/LocationPickerViewModelTest.kt @@ -7,9 +7,9 @@ package com.nextcloud.talk.location.viewmodels import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.location.GeocodingResult import com.nextcloud.talk.logger.Logger -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -30,11 +30,11 @@ class LocationPickerViewModelTest { private lateinit var viewModel: LocationPickerViewModel private val ncApi: NcApi = mock() - private val userProvider: CurrentUserProviderOld = mock() private val okHttpClient: OkHttpClient = mock() private val logger: Logger = mock() private val initParams = LocationPickerViewModel.LocationPickerInitParams( + user = User(id = 1L, username = "user1", baseUrl = "https://server1", token = "token"), roomToken = "testRoom", chatApiVersion = 1, geocodingResult = null, @@ -48,7 +48,7 @@ class LocationPickerViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - viewModel = LocationPickerViewModel(ncApi, userProvider, okHttpClient, logger) + viewModel = LocationPickerViewModel(ncApi, okHttpClient, logger) viewModel.initialize(initParams) } diff --git a/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt b/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt index 70bb649bef..1e74b30740 100644 --- a/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/login/data/LoginRepositoryTest.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.login.data -import android.os.Bundle import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.LiveData import androidx.work.WorkInfo @@ -30,6 +29,7 @@ import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -402,7 +402,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNull(result) + assertEquals(LoginRepository.LoginResult.ExistingAccount, result) verify(localLoginDataSource).startAccountRemovalWorker() } @@ -420,8 +420,8 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNull(result) - verify(localLoginDataSource, never()).updateUser(any()) + assertEquals(LoginRepository.LoginResult.ExistingAccount, result) + verify(localLoginDataSource, never()).updateUser(any(), anyOrNull()) } @Test @@ -436,13 +436,15 @@ class LoginRepositoryTest { .thenReturn(false) whenever(localLoginDataSource.checkIfUserExists(loginData)) .thenReturn(true) + whenever(localLoginDataSource.updateUser(loginData, null)) + .thenReturn(true) // Act val result = repo.parseAndLogin(loginData) // Assert - assertNull(result) - verify(localLoginDataSource).updateUser(loginData) + assertEquals(LoginRepository.LoginResult.ExistingAccount, result) + verify(localLoginDataSource).updateUser(loginData, null) } @Test @@ -459,8 +461,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNotNull(result) - assertTrue(result is Bundle) + assertTrue(result is LoginRepository.LoginResult.NewAccount) } @Test @@ -477,8 +478,7 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNotNull(result) - assertTrue(result is Bundle) + assertTrue(result is LoginRepository.LoginResult.NewAccount) } @Test @@ -495,8 +495,28 @@ class LoginRepositoryTest { val result = repo.parseAndLogin(loginData) // Assert - assertNotNull(result) - assertTrue(result is Bundle) + assertTrue(result is LoginRepository.LoginResult.NewAccount) + } + + @Test + fun `parseAndLogin reports a different account for an account that is not set up during a reauthorization`() = + runTest { + // Arrange - reauthorize the account with the internal id 7 + val qrData = "nc://login/server:https%3A//example.com" + repo.startLoginFlowFromQR(qrData, reAuth = true, accountToReauthorize = 7L) + + val loginData = LoginCompletion(200, "https://server.com", "newuser", "apppass123") + whenever(localLoginDataSource.checkIfUserIsScheduledForDeletion(loginData)) + .thenReturn(false) + whenever(localLoginDataSource.checkIfUserExists(loginData)) + .thenReturn(false) + + // Act + val result = repo.parseAndLogin(loginData) + + // Assert + assertEquals(LoginRepository.LoginResult.DifferentAccount, result) + verify(localLoginDataSource, never()).updateUser(any(), anyOrNull()) } // ========== LocalLoginDataSource Integration Tests ========== @@ -534,7 +554,7 @@ class LoginRepositoryTest { // Assert verify(localLoginDataSource).checkIfUserExists(loginData) - verify(localLoginDataSource, never()).updateUser(any()) + verify(localLoginDataSource, never()).updateUser(any(), anyOrNull()) } @Test @@ -549,12 +569,37 @@ class LoginRepositoryTest { .thenReturn(false) whenever(localLoginDataSource.checkIfUserExists(loginData)) .thenReturn(true) + whenever(localLoginDataSource.updateUser(loginData, null)) + .thenReturn(true) // Act repo.parseAndLogin(loginData) // Assert - verify(localLoginDataSource).updateUser(loginData) + verify(localLoginDataSource).updateUser(loginData, null) + } + + @Test + fun `parseAndLogin passes the account to reauthorize and reports a different account`() = + runTest { + // Arrange - reauthorize the account with the internal id 7 + val qrData = "nc://login/server:https%3A//example.com" + repo.startLoginFlowFromQR(qrData, reAuth = true, accountToReauthorize = 7L) + + val loginData = LoginCompletion(200, "https://server.com", "otheruser", "apppass123") + whenever(localLoginDataSource.checkIfUserIsScheduledForDeletion(loginData)) + .thenReturn(false) + whenever(localLoginDataSource.checkIfUserExists(loginData)) + .thenReturn(true) + whenever(localLoginDataSource.updateUser(loginData, 7L)) + .thenReturn(false) + + // Act + val result = repo.parseAndLogin(loginData) + + // Assert + assertEquals(LoginRepository.LoginResult.DifferentAccount, result) + verify(localLoginDataSource).updateUser(loginData, 7L) } // ========== Edge Cases and Error Handling ========== diff --git a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt index ea64bcc39c..cd7ce92e3c 100644 --- a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt @@ -10,13 +10,13 @@ package com.nextcloud.talk.messagesearch import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.test.fakes.FakeUnifiedSearchRepository import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl import com.nextcloud.talk.utils.preview.DummyUserDaoImpl +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Before @@ -36,8 +36,8 @@ class MessageSearchHelperTest { val userManager: UserManager get() = UserManager(userRepository) - val userProvider: CurrentUserProviderOld - get() = CurrentUserProviderOldImpl(userManager) + val currentUser: User + get() = runBlocking { userManager.getDefaultUser() }!! @Suppress("LongParameterList") private fun createMessageEntry( @@ -64,7 +64,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -80,7 +80,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -96,7 +96,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -115,7 +115,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -140,7 +140,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val result = sut.startMessageSearch("foo") @@ -158,7 +158,7 @@ class MessageSearchHelperTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) repeat(5) { @@ -173,7 +173,7 @@ class MessageSearchHelperTest { runTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) Assert.assertEquals(null, sut.loadMore()) } @@ -183,7 +183,7 @@ class MessageSearchHelperTest { runTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val firstPageEntries = (1..5).map { createMessageEntry() } @@ -214,7 +214,7 @@ class MessageSearchHelperTest { runTest { val sut = MessageSearchHelper( repository, - currentUser = userProvider.currentUser.blockingGet() + currentUser = currentUser ) val firstPageEntries = listOf( diff --git a/app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt b/app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt new file mode 100644 index 0000000000..19d6edf230 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/users/DefaultAccountProviderTest.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.users + +import com.nextcloud.talk.data.user.UsersRepository +import com.nextcloud.talk.data.user.model.User +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +class DefaultAccountProviderTest { + + private val usersRepository: UsersRepository = mock() + private val userManager = UserManager(usersRepository) + private val provider = DefaultAccountProvider(userManager) + + private val userA = User(id = 1, username = "userA", baseUrl = "https://example.com", current = true) + + @Before + fun setUp() { + wheneverBlocking { usersRepository.getActiveUserFlow() }.thenReturn(emptyFlow()) + } + + @Test + fun `getDefaultUserBlocking loads the active user when none is known yet`() { + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(userA) + + assertEquals(userA, provider.getDefaultUserBlocking()) + } + + @Test + fun `getDefaultUserBlocking returns the known default user without querying the database`() = + runTest { + wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(true) + wheneverBlocking { usersRepository.getUserWithId(1L) }.thenReturn(userA) + userManager.setUserAsActive(userA) + + assertEquals(userA, provider.getDefaultUserBlocking()) + verifyBlocking(usersRepository, never()) { getActiveUser() } + } + + @Test + fun `getDefaultUserBlocking is null when there is no account`() { + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(null) + wheneverBlocking { usersRepository.getUsersNotScheduledForDeletion() }.thenReturn(emptyList()) + + assertNull(provider.getDefaultUserBlocking()) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt index 448edbdaf3..c67a447b79 100644 --- a/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt @@ -10,6 +10,8 @@ import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.ExternalSignalingServer import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -44,19 +46,54 @@ class UserManagerTest { } @Test - fun `getCurrentUser returns the active user without touching any fallback`() = + fun `getDefaultUser returns the active user without touching any fallback`() = runTest { val active = user(id = 1, username = "userA", baseUrl = "https://example.com", current = true) wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) - val result = userManager.getCurrentUser() + val result = userManager.getDefaultUser() assertEquals(active, result) verifyBlocking(usersRepository, never()) { getUsersNotScheduledForDeletion() } } @Test - fun `getCurrentUser falls back to any non-deleted user and sets it active when none is active`() = + fun `getDefaultUser does not write to the database when an active user exists`() = + runTest { + val active = user(id = 1, username = "userA", baseUrl = "https://example.com", current = true) + wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(active) + + userManager.getDefaultUser() + + verifyBlocking(usersRepository, never()) { setUserAsActiveWithId(any()) } + } + + @Test + fun `userFlow emits the user with the given id and skips unchanged rows`() = + runTest { + val userA = user(id = 1, username = "userA", baseUrl = "https://example.com") + val renamedUserA = userA.copy(displayName = "New Name") + wheneverBlocking { usersRepository.getUserWithIdFlow(1L) } + .thenReturn(flowOf(userA, userA.copy(), renamedUserA, null)) + + val emissions = userManager.userFlow(1L).toList() + + assertEquals(listOf(userA, renamedUserA, null), emissions) + } + + @Test + fun `repairMultipleActiveUsers repairs in a single repository call`() = + runTest { + wheneverBlocking { usersRepository.repairMultipleActiveUsers() }.thenReturn(2) + + userManager.repairMultipleActiveUsers() + + verifyBlocking(usersRepository) { repairMultipleActiveUsers() } + verifyBlocking(usersRepository, never()) { setUserAsActiveWithId(any()) } + } + + @Test + fun `getDefaultUser falls back to any non-deleted user and sets it active when none is active`() = runTest { val fallback = user(id = 1, username = "userA", baseUrl = "https://example.com") wheneverBlocking { usersRepository.getUsersNotScheduledForDeletion() }.thenReturn(listOf(fallback)) @@ -65,18 +102,18 @@ class UserManagerTest { // now reporting the freshly-activated row. wheneverBlocking { usersRepository.getActiveUser() }.thenReturn(null, fallback) - val result = userManager.getCurrentUser() + val result = userManager.getDefaultUser() assertEquals(fallback, result) verifyBlocking(usersRepository) { setUserAsActiveWithId(fallback.id!!) } } @Test - fun `getCurrentUser is null when there is no active user and none to fall back to`() = + fun `getDefaultUser is null when there is no active user and none to fall back to`() = runTest { wheneverBlocking { usersRepository.getUsersNotScheduledForDeletion() }.thenReturn(emptyList()) - assertNull(userManager.getCurrentUser()) + assertNull(userManager.getDefaultUser()) } @Test @@ -183,10 +220,11 @@ class UserManagerTest { @Test fun `updateExternalSignalingServer throws when the user does not exist`() = runTest { - wheneverBlocking { usersRepository.getUserWithId(7L) }.thenReturn(null) + val server = ExternalSignalingServer() + wheneverBlocking { usersRepository.updateExternalSignalingServer(7L, server) }.thenReturn(0) try { - userManager.updateExternalSignalingServer(7L, ExternalSignalingServer()) + userManager.updateExternalSignalingServer(7L, server) fail("Expected NoSuchElementException") } catch (expected: NoSuchElementException) { // expected @@ -196,15 +234,13 @@ class UserManagerTest { @Test fun `updateExternalSignalingServer updates the matching user`() = runTest { - val existing = user(id = 7, username = "userA", baseUrl = "https://example.com") val server = ExternalSignalingServer(externalSignalingServer = "https://signaling.example.com") - wheneverBlocking { usersRepository.getUserWithId(7L) }.thenReturn(existing) - wheneverBlocking { usersRepository.updateUser(existing) }.thenReturn(1) + wheneverBlocking { usersRepository.updateExternalSignalingServer(7L, server) }.thenReturn(1) val result = userManager.updateExternalSignalingServer(7L, server) assertEquals(1, result) - assertEquals(server, existing.externalSignalingServer) + verify(usersRepository, never()).updateUser(any()) } @Test @@ -232,7 +268,7 @@ class UserManagerTest { } @Test - fun `setUserAsActive publishes the new user on currentUserFlow only when it succeeds`() = + fun `setUserAsActive publishes the new user on defaultUserFlow only when it succeeds`() = runTest { val target = user(id = 1, username = "userA", baseUrl = "https://example.com") wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(true) @@ -240,11 +276,24 @@ class UserManagerTest { val result = userManager.setUserAsActive(target) assertTrue(result) - assertEquals(target, userManager.currentUserFlow.value) + assertEquals(target, userManager.defaultUserFlow.value) + } + + @Test + fun `setUserAsActive publishes the stored row instead of the passed user`() = + runTest { + val passed = user(id = 1, username = "userA", baseUrl = "https://example.com") + val stored = passed.copy(current = true, displayName = "Stored Name") + wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(true) + wheneverBlocking { usersRepository.getUserWithId(1L) }.thenReturn(stored) + + userManager.setUserAsActive(passed) + + assertEquals(stored, userManager.defaultUserFlow.value) } @Test - fun `setUserAsActive leaves currentUserFlow untouched when it fails`() = + fun `setUserAsActive leaves defaultUserFlow untouched when it fails`() = runTest { val target = user(id = 1, username = "userA", baseUrl = "https://example.com") wheneverBlocking { usersRepository.setUserAsActiveWithId(1L) }.thenReturn(false) @@ -252,7 +301,7 @@ class UserManagerTest { val result = userManager.setUserAsActive(target) assertFalse(result) - assertNull(userManager.currentUserFlow.value) + assertNull(userManager.defaultUserFlow.value) } @Test diff --git a/app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt new file mode 100644 index 0000000000..aa1e79e34e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/CredentialsCookieInterceptorTest.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.net.CookieManager + +class CredentialsCookieInterceptorTest { + + private val server = MockWebServer() + private val cookieManager = CookieManager() + private lateinit var client: OkHttpClient + + @Before + fun setUp() { + server.start() + client = OkHttpClient.Builder() + .cookieJar(JavaNetCookieJar(cookieManager)) + .addNetworkInterceptor(CredentialsCookieInterceptor()) + .build() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `a request with credentials doesn't send a stored session cookie`() { + server.enqueue(MockResponse().addHeader("Set-Cookie", "$SESSION_COOKIE; Path=/")) + server.enqueue(MockResponse()) + + // An anonymous request stores the session cookie. + execute(authorization = null) + assertEquals(1, cookieManager.cookieStore.cookies.size) + + execute(authorization = CREDENTIALS) + + server.takeRequest() + assertNull(server.takeRequest().getHeader("Cookie")) + } + + @Test + fun `a response to a request with credentials doesn't store its session cookie`() { + server.enqueue(MockResponse().addHeader("Set-Cookie", "$SESSION_COOKIE; Path=/")) + + execute(authorization = CREDENTIALS) + + assertTrue(cookieManager.cookieStore.cookies.isEmpty()) + } + + @Test + fun `a request without credentials keeps using cookies`() { + server.enqueue(MockResponse().addHeader("Set-Cookie", "$SESSION_COOKIE; Path=/")) + server.enqueue(MockResponse()) + + execute(authorization = null) + execute(authorization = null) + + server.takeRequest() + assertEquals(SESSION_COOKIE, server.takeRequest().getHeader("Cookie")) + } + + private fun execute(authorization: String?) { + val request = Request.Builder() + .url(server.url("/ocs/v2.php/apps/spreed/api/v4/room")) + .apply { authorization?.let { header("Authorization", it) } } + .build() + client.newCall(request).execute().close() + } + + companion object { + private const val SESSION_COOKIE = "nc_session_id=abc" + private const val CREDENTIALS = "Basic bWFyY2VsMjp0b2tlbg==" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt new file mode 100644 index 0000000000..9ef533203d --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/RemoteWipeInterceptorTest.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.Context +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ssl.SSLSocketFactoryCompat +import com.nextcloud.talk.utils.ssl.TrustManager +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +/** + * A 401 must only remove the account whose credentials the request carried. These cases must never remove one. + */ +class RemoteWipeInterceptorTest { + + private val userManager: UserManager = mock() + private val interceptor = RemoteWipeInterceptor( + userManager, + mock(), + mock(), + mock() + ) + + private val marcel = User(id = 1, username = "marcel", token = "tokenA", baseUrl = BASE_URL) + private val marcel2 = User(id = 2, username = "marcel2", token = "tokenB", baseUrl = BASE_URL) + + @Test + fun `a 401 for a request without credentials removes no account`() { + wheneverBlocking { userManager.getUsers() } doReturn listOf(marcel, marcel2) + + val response = interceptor.intercept(chainAnswering401(Request.Builder().url(PREVIEW_URL).build())) + + assertEquals(HTTP_UNAUTHORIZED, response.code) + verifyBlocking(userManager, never()) { scheduleUserForDeletionWithId(any()) } + } + + @Test + fun `a 401 for credentials of no stored account removes no account`() { + wheneverBlocking { userManager.getUsers() } doReturn listOf(marcel, marcel2) + val request = Request.Builder() + .url(PREVIEW_URL) + .header("Authorization", Credentials.basic("marcel2", "outdatedToken")) + .build() + + interceptor.intercept(chainAnswering401(request)) + + verifyBlocking(userManager, never()) { scheduleUserForDeletionWithId(any()) } + } + + private fun chainAnswering401(request: Request): Interceptor.Chain { + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(HTTP_UNAUTHORIZED) + .message("Unauthorized") + .build() + return mock { + on { request() } doReturn request + on { proceed(any()) } doReturn response + } + } + + companion object { + private const val BASE_URL = "https://cloud.example.com" + private const val PREVIEW_URL = "$BASE_URL/index.php/core/preview?fileId=1" + private const val HTTP_UNAUTHORIZED = 401 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt index 37ba993f26..065d56597c 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/UriUtilsTest.kt @@ -28,4 +28,30 @@ class UriUtilsTest { val link = UriUtils.getMessageLink("https://example.com/nextcloud", "token1", 7) Assert.assertEquals("https://example.com/nextcloud/call/token1#message_7", link) } + + @Test + fun `isOnServer accepts URLs of the server`() { + Assert.assertTrue(UriUtils.isOnServer("$SERVER/core/preview?x=1", SERVER)) + Assert.assertTrue(UriUtils.isOnServer("$SERVER/", "$SERVER/")) + Assert.assertTrue(UriUtils.isOnServer("$SUBFOLDER_SERVER/index.php", SUBFOLDER_SERVER)) + } + + @Test + fun `isOnServer rejects other hosts that start with the server`() { + Assert.assertFalse(UriUtils.isOnServer("https://cloud.example.com.attacker.test/image", SERVER)) + Assert.assertFalse(UriUtils.isOnServer("https://cloud.example.com@attacker.test/image", SERVER)) + } + + @Test + fun `isOnServer rejects another scheme, port or path`() { + Assert.assertFalse(UriUtils.isOnServer("http://cloud.example.com/image", SERVER)) + Assert.assertFalse(UriUtils.isOnServer("https://cloud.example.com:8443/image", SERVER)) + Assert.assertFalse(UriUtils.isOnServer("${SUBFOLDER_SERVER}2/image", SUBFOLDER_SERVER)) + Assert.assertFalse(UriUtils.isOnServer("not a url", SERVER)) + } + + companion object { + private const val SERVER = "https://cloud.example.com" + private const val SUBFOLDER_SERVER = "https://example.com/nextcloud" + } } diff --git a/app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt new file mode 100644 index 0000000000..a1d8a38f9a --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/ssl/ClientCertificateSelectorTest.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils.ssl + +import com.nextcloud.talk.data.user.model.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ClientCertificateSelectorTest { + + private val withCertA = User(id = 1, baseUrl = "https://a.example.com", clientCertificate = "certA") + private val withoutCertB = User(id = 2, baseUrl = "https://b.example.com") + private val withCertB = User(id = 3, baseUrl = "https://b.example.com", clientCertificate = "certB") + private val otherCertB = User(id = 4, baseUrl = "https://b.example.com", clientCertificate = "otherB") + + @Test + fun `the certificate of the server's account is used, not the default account's`() { + val alias = ClientCertificateSelector.selectAlias( + "a.example.com", + listOf(withCertA, withoutCertB), + withoutCertB + ) + + assertEquals("certA", alias) + } + + @Test + fun `no certificate for a server whose accounts use none, even if the default account has one`() { + val alias = ClientCertificateSelector.selectAlias("b.example.com", listOf(withCertA, withoutCertB), withCertA) + + assertNull(alias) + } + + @Test + fun `accounts on the same server with one certificate share it`() { + val alias = ClientCertificateSelector.selectAlias( + "b.example.com", + listOf(withoutCertB, withCertB), + withoutCertB + ) + + assertEquals("certB", alias) + } + + @Test + fun `different certificates on the same server are decided by the default account`() { + val users = listOf(withCertB, otherCertB, withCertA) + + assertEquals("otherB", ClientCertificateSelector.selectAlias("b.example.com", users, otherCertB)) + assertNull(ClientCertificateSelector.selectAlias("b.example.com", users, withCertA)) + } + + @Test + fun `without a host the default account's certificate is used`() { + assertEquals("certA", ClientCertificateSelector.selectAlias(null, listOf(withCertA, withCertB), withCertA)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt index 7efd5c9a15..e619c8ebf4 100644 --- a/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt @@ -6,14 +6,8 @@ */ package com.nextcloud.talk.viewmodels -import com.nextcloud.talk.data.user.UsersDao -import com.nextcloud.talk.data.user.UsersRepository -import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.test.fakes.FakeCallRecordingRepository -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl -import com.nextcloud.talk.utils.preview.DummyUserDaoImpl import com.vividsolutions.jts.util.Assert import org.junit.Before import org.junit.Test @@ -23,17 +17,7 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { private val repository = FakeCallRecordingRepository() - val usersDao: UsersDao - get() = DummyUserDaoImpl() - - val userRepository: UsersRepository - get() = UsersRepositoryImpl(usersDao) - - val userManager: UserManager - get() = UserManager(userRepository) - - val userProvider: CurrentUserProviderOld - get() = CurrentUserProviderOldImpl(userManager) + private val user = User(id = 1L, username = "user1", baseUrl = "https://server1", token = "token") @Before fun setUp() { @@ -42,8 +26,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_clickStartRecord() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.clickRecordButton() Assert.isTrue(viewModel.viewState.value is CallRecordingViewModel.RecordingStartingState) @@ -56,8 +40,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_clickStopRecord() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE) Assert.equals(true, (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo) @@ -73,8 +57,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_keepConfirmState() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE) Assert.equals(true, (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo) @@ -90,8 +74,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testCallRecordingViewModel_continueRecordingWhenDismissStopDialog() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE) viewModel.clickRecordButton() @@ -109,8 +93,8 @@ class CallRecordingViewModelTest : AbstractViewModelTest() { @Test fun testSetRecordingStateDirectly() { - val viewModel = CallRecordingViewModel(repository, userProvider) - viewModel.setData("foo") + val viewModel = CallRecordingViewModel(repository) + viewModel.setData(user, "foo") viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STOPPED_CODE) Assert.isTrue(viewModel.viewState.value is CallRecordingViewModel.RecordingStoppedState)