diff --git a/lib/build.gradle b/lib/build.gradle index 834388e9..fc51fcd2 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -92,6 +92,7 @@ android { } testOptions { targetSdk 36 + unitTests.returnDefaultValues = true } } diff --git a/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java b/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java index bc79b59a..3f80f789 100644 --- a/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java +++ b/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java @@ -48,7 +48,8 @@ public class AidlNetworkRequest extends NetworkRequest { private static final String TAG = AidlNetworkRequest.class.getCanonicalName(); private IInputStreamService mService = null; - private final AtomicBoolean mBound = new AtomicBoolean(false); // Flag indicating whether we have called bind on the service + private final AtomicBoolean mBound = new AtomicBoolean(false); // Flag indicating whether the service connection has been established + private final AtomicBoolean mBindingRequested = new AtomicBoolean(false); // Flag indicating whether we have called bind on the service AidlNetworkRequest(@NonNull Context context, @NonNull SingleSignOnAccount account, @NonNull NextcloudAPI.ApiConnectedListener callback) { super(context, account, callback); @@ -61,12 +62,19 @@ public class AidlNetworkRequest extends NetworkRequest { public void onServiceConnected(ComponentName className, IBinder service) { Log.d(TAG, "[onServiceConnected] called from Thread: [" + Thread.currentThread().getName() + "] with IBinder [" + className.toString() + "]: " + service); + final NextcloudAPI.ApiConnectedListener callback = mCallback; + if (mDestroyed || callback == null) { + // API has been closed while the connection was still being established + Log.w(TAG, "[onServiceConnected] API already closed - ignoring connection to [" + className + "]"); + return; + } + mService = IInputStreamService.Stub.asInterface(service); mBound.set(true); synchronized (mBound) { mBound.notifyAll(); } - mCallback.onConnected(); + callback.onConnected(); } public void onServiceDisconnected(ComponentName className) { @@ -105,6 +113,7 @@ public void connect(String type) { throw new IllegalStateException("Binding to AccountManagerService returned false"); } else { Log.d(TAG, "[connect] Bound to AccountManagerService successfully"); + mBindingRequested.set(true); } } catch (SecurityException e) { Log.e(TAG, "[connect] can't bind to AccountManagerService, check permission in Manifest"); @@ -127,17 +136,19 @@ public void close() { } private void unbindService() { - // Unbind from the service - if (mBound.get()) { + // Unbind whenever a binding has been requested: the ServiceConnection stays registered + // even if the connection has not been established yet and would otherwise still receive + // onServiceConnected() after this API has been closed + if (mBindingRequested.getAndSet(false)) { if (mContext != null) { Log.d(TAG, "[unbindService] Unbinding AccountManagerService"); mContext.unbindService(mConnection); } else { Log.e(TAG, "[unbindService] Context was null, cannot unbind nextcloud single sign-on service connection!"); } - mBound.set(false); - mService = null; } + mBound.set(false); + mService = null; } private void waitForApi() throws NextcloudApiNotRespondingException { diff --git a/lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.java b/lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.java new file mode 100644 index 00000000..a1c20c62 --- /dev/null +++ b/lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.java @@ -0,0 +1,80 @@ +/* + * Nextcloud Android SingleSignOn Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.android.sso.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; + +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +public class AidlNetworkRequestTest { + + private static final String ACCOUNT_TYPE = "nextcloud"; + + private Context context; + private NextcloudAPI.ApiConnectedListener callback; + private AidlNetworkRequest request; + + @Before + public void setUp() { + context = mock(Context.class); + callback = mock(NextcloudAPI.ApiConnectedListener.class); + final var account = new SingleSignOnAccount("name", "userId", "token", "https://example.com", ACCOUNT_TYPE); + request = new AidlNetworkRequest(context, account, callback); + } + + private ServiceConnection connectAndCaptureServiceConnection() { + when(context.bindService(any(Intent.class), any(ServiceConnection.class), anyInt())).thenReturn(true); + request.connect(ACCOUNT_TYPE); + + final var connectionCaptor = ArgumentCaptor.forClass(ServiceConnection.class); + verify(context).bindService(any(Intent.class), connectionCaptor.capture(), anyInt()); + return connectionCaptor.getValue(); + } + + @Test + public void onServiceConnectedBeforeCloseInvokesCallback() { + final var connection = connectAndCaptureServiceConnection(); + + connection.onServiceConnected(new ComponentName("com.nextcloud.client", "AccountManagerService"), mock(IBinder.class)); + + verify(callback).onConnected(); + } + + @Test + public void onServiceConnectedAfterCloseDoesNotThrowAndDoesNotInvokeCallback() { + final var connection = connectAndCaptureServiceConnection(); + + request.close(); + connection.onServiceConnected(new ComponentName("com.nextcloud.client", "AccountManagerService"), mock(IBinder.class)); + + verify(callback, never()).onConnected(); + } + + @Test + public void closeBeforeServiceConnectedUnbindsServiceConnection() { + final var connection = connectAndCaptureServiceConnection(); + + request.close(); + + verify(context).unbindService(connection); + } +}