From b548ea9289e991cea9f20fa6f5df9ab4bbb5d91d Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 07:15:22 +0000 Subject: [PATCH 1/4] fix(login): wait for the pending account removal before logging in again After "Remove account" the app restarted as soon as AccountRemovalWorker was SUCCEEDED, but the worker only started the push proxy request (async) and returned. The user row was still scheduled for deletion, so logging in with the same account ended in ExistingAccount and an app restart to the server address screen, without any message. The worker now blocks on the proxy request, and also deletes the user when the Nextcloud unregistration answers with another status than 200/202. When a login finds an account still scheduled for deletion, LoginRepository starts the removal and waits for it (30 s), then sets the account up as a new one. If the removal does not finish in time, an error is shown instead of silently restarting. Assisted-by: Claude-Code:claude-opus-5-5 Signed-off-by: Krainov Gleb --- .../talk/account/BrowserLoginActivity.kt | 5 +++ .../talk/account/data/LoginRepository.kt | 17 +++++-- .../account/data/io/LocalLoginDataSource.kt | 20 +++++++++ .../BrowserLoginActivityViewModel.kt | 2 + .../talk/jobs/AccountRemovalWorker.java | 7 ++- app/src/main/res/values/strings.xml | 1 + .../data/io/LocalLoginDataSourceTest.kt | 45 +++++++++++++++++++ .../talk/login/data/LoginRepositoryTest.kt | 24 +++++++++- 8 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt 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 649fb7c6e6b..87e83bc2414 100644 --- a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt @@ -118,6 +118,11 @@ class BrowserLoginActivity : BaseActivity() { Snackbar.make(binding.root, R.string.nc_login_too_many_attempts, Snackbar.LENGTH_LONG) .show() } + BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRemovalPending -> { + logger.e(TAG, "Account removal did not finish in time") + Snackbar.make(binding.root, R.string.nc_account_removal_pending, Snackbar.LENGTH_LONG) + .show() + } BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRestartApp -> { restartApp() } 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 a51604352aa..8940141a724 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 @@ -52,6 +52,9 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin /** An existing account, reauthorized or not, or an account that is being removed. */ data object ExistingAccount : LoginResult + /** The account is still being removed, so it can't be set up again yet. */ + data object RemovalPending : LoginResult + /** A reauthorization in which a different account than the one to reauthorize logged in. */ data object DifferentAccount : LoginResult } @@ -211,16 +214,22 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin } /** - * Returns [LoginResult.NewAccount] if the account is not scheduled for deletion and doesn't exist yet. During a + * Returns [LoginResult.NewAccount] if the account doesn't exist yet. An account that is scheduled for deletion is + * waited for first, [LoginResult.RemovalPending] if its removal does not finish in time. 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): LoginResult { if (local.checkIfUserIsScheduledForDeletion(loginData)) { - // however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it. + // The account was removed just before and the removal did not finish yet. Make sure it runs and wait for + // it, as the account can then be set up again. Otherwise the login would end without any account. local.startAccountRemovalWorker() - return LoginResult.ExistingAccount - } else if (local.checkIfUserExists(loginData)) { + if (!local.awaitUserRemoval(loginData)) { + return LoginResult.RemovalPending + } + } + + if (local.checkIfUserExists(loginData)) { if (shouldReauthorizeUser) { if (!local.updateUser(loginData, accountToReauthorize)) { Log.w(TAG, "Logged in with a different account than the one to reauthorize. Skipped update.") 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 395537520f2..b6b40ca4549 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,11 +17,18 @@ 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.delay +import kotlinx.coroutines.withTimeoutOrNull // 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) { + companion object { + private const val REMOVAL_TIMEOUT_MILLIS = 30_000L + private const val REMOVAL_POLL_INTERVAL_MILLIS = 200L + } + /** * 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 @@ -48,6 +55,19 @@ class LocalLoginDataSource(val userManager: UserManager, val appPreferences: App return WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id) } + /** + * Waits until the account of [data] is not scheduled for deletion anymore, i.e. the account removal finished, for + * at most [timeoutMillis]. Returns false if it still is. The removal also contacts the server and the push proxy, + * so it can take a while. + */ + suspend fun awaitUserRemoval(data: LoginCompletion, timeoutMillis: Long = REMOVAL_TIMEOUT_MILLIS): Boolean = + withTimeoutOrNull(timeoutMillis) { + while (checkIfUserIsScheduledForDeletion(data)) { + delay(REMOVAL_POLL_INTERVAL_MILLIS) + } + true + } ?: false + suspend fun checkIfUserIsScheduledForDeletion(data: LoginCompletion): Boolean = userManager.checkIfUserIsScheduledForDeletion(data.loginName, data.server) 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 9ab868f8c02..43cf3877727 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 @@ -41,6 +41,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep data object PostLoginTooManyLoginAttempts : PostLoginViewState() data class PostLoginContinue(val data: Bundle) : PostLoginViewState() data object PostLoginDifferentAccount : PostLoginViewState() + data object PostLoginRemovalPending : PostLoginViewState() } private val _postLoginState = MutableStateFlow(PostLoginViewState.None) @@ -121,6 +122,7 @@ class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRep is LoginRepository.LoginResult.NewAccount -> PostLoginViewState.PostLoginContinue(result.bundle) LoginRepository.LoginResult.ExistingAccount -> PostLoginViewState.PostLoginRestartApp LoginRepository.LoginResult.DifferentAccount -> PostLoginViewState.PostLoginDifferentAccount + LoginRepository.LoginResult.RemovalPending -> PostLoginViewState.PostLoginRemovalPending } fun cancelLogin() = repository.cancelLoginFlow() diff --git a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java index 4062d2d27b4..8ef8f3a72c6 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java @@ -129,6 +129,9 @@ public void onNext(@io.reactivex.annotations.NonNull GenericOverall genericOvera queryMap.put("deviceIdentifierSignature", finalPushConfigurationState.getDeviceIdentifierSignature()); unregisterDeviceForNotificationWithProxy(queryMap, user); + } else { + Log.e(TAG, "unregister Device For Notifications failed with status " + statusCode); + initiateUserDeletion(user); } } @@ -154,7 +157,9 @@ public void onComplete() { private void unregisterDeviceForNotificationWithProxy(HashMap queryMap, User user) { ncApi.unregisterDeviceForNotificationsWithProxy (ApiUtils.getUrlPushProxy(), queryMap) - .subscribe(new Observer() { + // Blocking, so that doWork() returns only after the user is deleted. Callers restart the app or + // log in again as soon as the work is SUCCEEDED, and the user must be gone by then. + .blockingSubscribe(new Observer() { @Override public void onSubscribe(Disposable d) { // unused atm diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3be4fb559e4..c5ac7e08e72 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ How to translate with transifex: Dismiss Sorry, something went wrong! Too many failed login attempts from this network. Try again later. + The removal of this account is not finished yet. Please try again in a moment. You logged in with a different account. Log in with the account you want to reauthorize. Create Unknown diff --git a/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt b/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt new file mode 100644 index 00000000000..b447b2d9a2e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.account.data.io + +import com.nextcloud.talk.account.data.model.LoginCompletion +import com.nextcloud.talk.users.UserManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalLoginDataSourceTest { + + private val userManager: UserManager = mock() + private val dataSource = LocalLoginDataSource(userManager, mock(), mock()) + private val loginData = LoginCompletion(200, "https://example.com", "user", "pass") + + @Test + fun `awaitUserRemoval returns once the user is not scheduled for deletion anymore`() = + runTest { + whenever(userManager.checkIfUserIsScheduledForDeletion("user", "https://example.com")) + .thenReturn(true, true, false) + + assertTrue(dataSource.awaitUserRemoval(loginData, 10_000L)) + verify(userManager, times(3)).checkIfUserIsScheduledForDeletion("user", "https://example.com") + } + + @Test + fun `awaitUserRemoval gives up when the removal does not finish in time`() = + runTest { + whenever(userManager.checkIfUserIsScheduledForDeletion("user", "https://example.com")) + .thenReturn(true) + + assertFalse(dataSource.awaitUserRemoval(loginData, 1_000L)) + } +} 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 1e74b307402..1fc9275df59 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 @@ -389,7 +389,7 @@ class LoginRepositoryTest { // ========== parseAndLogin() Tests ========== @Test - fun `parseAndLogin returns null when user is scheduled for deletion`() = + fun `parseAndLogin sets the account up again once its pending removal finished`() = runTest { // Arrange val loginData = LoginCompletion(200, "https://server.com", "testuser", "apppass123") @@ -397,13 +397,31 @@ class LoginRepositoryTest { .thenReturn(true) whenever(localLoginDataSource.startAccountRemovalWorker()) .thenReturn(liveData) + whenever(localLoginDataSource.awaitUserRemoval(any(), any())).thenReturn(true) + + whenever(localLoginDataSource.checkIfUserExists(loginData)).thenReturn(false) // Act val result = repo.parseAndLogin(loginData) // Assert - assertEquals(LoginRepository.LoginResult.ExistingAccount, result) + assertTrue(result is LoginRepository.LoginResult.NewAccount) verify(localLoginDataSource).startAccountRemovalWorker() + verify(localLoginDataSource).awaitUserRemoval(any(), any()) + } + + @Test + fun `parseAndLogin reports a pending removal that does not finish in time`() = + runTest { + val loginData = LoginCompletion(200, "https://server.com", "testuser", "apppass123") + whenever(localLoginDataSource.checkIfUserIsScheduledForDeletion(loginData)).thenReturn(true) + whenever(localLoginDataSource.startAccountRemovalWorker()).thenReturn(liveData) + whenever(localLoginDataSource.awaitUserRemoval(any(), any())).thenReturn(false) + + val result = repo.parseAndLogin(loginData) + + assertEquals(LoginRepository.LoginResult.RemovalPending, result) + verify(localLoginDataSource, never()).checkIfUserExists(any()) } @Test @@ -530,6 +548,8 @@ class LoginRepositoryTest { .thenReturn(true) whenever(localLoginDataSource.startAccountRemovalWorker()) .thenReturn(liveData) + whenever(localLoginDataSource.awaitUserRemoval(any(), any())).thenReturn(true) + whenever(localLoginDataSource.checkIfUserExists(loginData)).thenReturn(false) // Act repo.parseAndLogin(loginData) From 4d25622352f94b05263c8c8d00f6b07d27179655 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 07:22:01 +0000 Subject: [PATCH 2/4] fix(login): treat the empty push proxy answer as success in the account removal The proxy answer has no body, so Retrofit emits null. A blocking subscriber queues the element and fails with a NullPointerException on it, which ended in onError for a successful request and skipped deleting the notification channel group. Wait for the request with ignoreElements() instead, which does not take the element, and delete the channel group when there is no error. Assisted-by: Claude-Code:claude-opus-5-5 Signed-off-by: Krainov Gleb --- .../talk/jobs/AccountRemovalWorker.java | 72 +++++++++---------- .../talk/jobs/AccountRemovalWorkerTest.java | 36 ++++++++++ 2 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java diff --git a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java index 8ef8f3a72c6..24ea7b4444b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java @@ -42,6 +42,7 @@ import androidx.work.Worker; import androidx.work.WorkerParameters; import autodagger.AutoInjector; +import io.reactivex.Observable; import io.reactivex.Observer; import io.reactivex.disposables.Disposable; import kotlin.coroutines.EmptyCoroutineContext; @@ -155,47 +156,40 @@ public void onComplete() { } private void unregisterDeviceForNotificationWithProxy(HashMap queryMap, User user) { - ncApi.unregisterDeviceForNotificationsWithProxy - (ApiUtils.getUrlPushProxy(), queryMap) - // Blocking, so that doWork() returns only after the user is deleted. Callers restart the app or - // log in again as soon as the work is SUCCEEDED, and the user must be gone by then. - .blockingSubscribe(new Observer() { - @Override - public void onSubscribe(Disposable d) { - // unused atm - } - - @Override - public void onNext(Void aVoid) { - String groupName = String.format( - getApplicationContext() - .getResources() - .getString(R.string.nc_notification_channel), user.getUserId(), user.getBaseUrl()); - CRC32 crc32 = new CRC32(); - crc32.update(groupName.getBytes()); - NotificationManager notificationManager = - (NotificationManager) getApplicationContext() - .getSystemService(Context.NOTIFICATION_SERVICE); - - if (notificationManager != null) { - notificationManager.deleteNotificationChannelGroup( - Long.toString(crc32.getValue())); - } - - initiateUserDeletion(user); - } + Throwable error = awaitCompletion( + ncApi.unregisterDeviceForNotificationsWithProxy(ApiUtils.getUrlPushProxy(), queryMap)); + + if (error == null) { + String groupName = String.format( + getApplicationContext() + .getResources() + .getString(R.string.nc_notification_channel), user.getUserId(), user.getBaseUrl()); + CRC32 crc32 = new CRC32(); + crc32.update(groupName.getBytes()); + NotificationManager notificationManager = + (NotificationManager) getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + + if (notificationManager != null) { + notificationManager.deleteNotificationChannelGroup( + Long.toString(crc32.getValue())); + } + } else { + Log.e(TAG, "error while trying to unregister Device For Notification With Proxy", error); + } - @Override - public void onError(Throwable e) { - Log.e(TAG, "error while trying to unregister Device For Notification With Proxy", e); - initiateUserDeletion(user); - } + initiateUserDeletion(user); + } - @Override - public void onComplete() { - // unused atm - } - }); + /** + * Blocks until the request ended, so that doWork() returns only after the user is deleted. Callers restart the + * app or log in again as soon as the work is SUCCEEDED, and the user must be gone by then. + * The elements are ignored: the body of the proxy answer is null, which a blocking subscriber can't take. + * + * @return the error, or null if the request succeeded + */ + static Throwable awaitCompletion(Observable request) { + return request.ignoreElements().blockingGet(); } private void initiateUserDeletion(User user) { diff --git a/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java new file mode 100644 index 00000000000..6bbb74dd90c --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java @@ -0,0 +1,36 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.jobs; + +import org.junit.Test; + +import io.reactivex.Observable; +import io.reactivex.disposables.Disposables; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class AccountRemovalWorkerTest { + + @Test + public void aSuccessfulRequestWithANullBodyIsRecognizedAsSuccess() { + // The push proxy answers without a body, Retrofit emits null. + Observable request = Observable.unsafeCreate(observer -> { + observer.onSubscribe(Disposables.empty()); + observer.onNext(null); + observer.onComplete(); + }); + + assertNull(AccountRemovalWorker.awaitCompletion(request)); + } + + @Test + public void aFailedRequestReturnsItsError() { + RuntimeException error = new RuntimeException("proxy unreachable"); + + assertSame(error, AccountRemovalWorker.awaitCompletion(Observable.error(error))); + } +} From c8637b1c44f36744b54eb75450b7ea542d5eea52 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 22 Oct 2026 12:16:30 +0200 Subject: [PATCH 3/4] fix(login): close the login screen when the account removal is still pending The login can't be started again in the same screen, so the hint to try again did not help there. Show it as a toast and finish the activity, as for a different account during a reauthorization. Also add the missing SPDX copyright lines to the new tests and drop the pending removal from the ExistingAccount description. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/account/BrowserLoginActivity.kt | 6 ++++-- .../java/com/nextcloud/talk/account/data/LoginRepository.kt | 2 +- .../talk/account/data/io/LocalLoginDataSourceTest.kt | 1 + .../com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) 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 87e83bc2414..fabf8a4a673 100644 --- a/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/BrowserLoginActivity.kt @@ -120,8 +120,10 @@ class BrowserLoginActivity : BaseActivity() { } BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRemovalPending -> { logger.e(TAG, "Account removal did not finish in time") - Snackbar.make(binding.root, R.string.nc_account_removal_pending, Snackbar.LENGTH_LONG) - .show() + // This login can't be retried here. Back on the previous screen, it can be started again, + // so the message must outlive this activity. + Toast.makeText(context, R.string.nc_account_removal_pending, Toast.LENGTH_LONG).show() + finish() } BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRestartApp -> { restartApp() 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 8940141a724..d0f52f72798 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 @@ -49,7 +49,7 @@ class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLogin /** 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. */ + /** An existing account, reauthorized or not. */ data object ExistingAccount : LoginResult /** The account is still being removed, so it can't be set up again yet. */ diff --git a/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt b/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt index b447b2d9a2e..9d229fa213a 100644 --- a/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt +++ b/app/src/test/java/com/nextcloud/talk/account/data/io/LocalLoginDataSourceTest.kt @@ -1,6 +1,7 @@ /* * 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.data.io diff --git a/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java index 6bbb74dd90c..6ce761bb74c 100644 --- a/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java +++ b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java @@ -1,6 +1,7 @@ /* * Nextcloud Talk - Android Client * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.jobs; From e298be42c7a53f8bc5d0cb354bb3c716c17281c3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 22 Oct 2026 12:58:02 +0200 Subject: [PATCH 4/4] test(login): migrate AccountRemovalWorkerTest to Kotlin Kotlin does not allow emitting null through the RxJava observer, so the test now calls the real push proxy endpoint of NcApi against a MockWebServer. The empty answer is the actual Retrofit behavior then, not a recreation of it. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../talk/jobs/AccountRemovalWorkerTest.java | 37 ----------- .../talk/jobs/AccountRemovalWorkerTest.kt | 65 +++++++++++++++++++ 2 files changed, 65 insertions(+), 37 deletions(-) delete mode 100644 app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java create mode 100644 app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.kt diff --git a/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java deleted file mode 100644 index 6ce761bb74c..00000000000 --- a/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.jobs; - -import org.junit.Test; - -import io.reactivex.Observable; -import io.reactivex.disposables.Disposables; - -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; - -public class AccountRemovalWorkerTest { - - @Test - public void aSuccessfulRequestWithANullBodyIsRecognizedAsSuccess() { - // The push proxy answers without a body, Retrofit emits null. - Observable request = Observable.unsafeCreate(observer -> { - observer.onSubscribe(Disposables.empty()); - observer.onNext(null); - observer.onComplete(); - }); - - assertNull(AccountRemovalWorker.awaitCompletion(request)); - } - - @Test - public void aFailedRequestReturnsItsError() { - RuntimeException error = new RuntimeException("proxy unreachable"); - - assertSame(error, AccountRemovalWorker.awaitCompletion(Observable.error(error))); - } -} diff --git a/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.kt new file mode 100644 index 00000000000..d57791f6a1a --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/jobs/AccountRemovalWorkerTest.kt @@ -0,0 +1,65 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.jobs + +import android.app.Application +import com.nextcloud.talk.api.NcApi +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.After +import org.junit.Assert.assertNull +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 +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class AccountRemovalWorkerTest { + + private val server = MockWebServer() + private lateinit var ncApi: NcApi + + @Before + fun setUp() { + server.start() + ncApi = Retrofit.Builder() + .baseUrl(server.url("/")) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build() + .create(NcApi::class.java) + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun `a successful request with an empty body is recognized as success`() { + // The push proxy answers without a body, Retrofit emits null. + server.enqueue(MockResponse()) + + val request = ncApi.unregisterDeviceForNotificationsWithProxy(server.url("/devices").toString(), emptyMap()) + + assertNull(AccountRemovalWorker.awaitCompletion(request)) + } + + @Test + fun `a failed request returns its error`() { + server.enqueue(MockResponse(code = 500)) + + val request = ncApi.unregisterDeviceForNotificationsWithProxy(server.url("/devices").toString(), emptyMap()) + + assertTrue(AccountRemovalWorker.awaitCompletion(request) is HttpException) + } +}