diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e96105fa02c..9853672cf171 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -396,6 +396,8 @@ dependencies { androidTestImplementation(libs.core.testing) // endregion + testImplementation(libs.kotlinx.coroutines.test) + // region other libraries compileOnly(libs.org.jbundle.util.osgi.wrapped.org.apache.http.client) implementation(libs.commons.httpclient.commons.httpclient) // remove after entire switch to lib v2 diff --git a/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt b/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt index 97ce82940e55..d12e00decdcc 100644 --- a/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt +++ b/app/src/androidTest/java/com/nextcloud/client/network/ConnectivityServiceImplIT.kt @@ -9,7 +9,6 @@ package com.nextcloud.client.network import android.accounts.AccountManager import android.content.Context -import android.net.ConnectivityManager import com.nextcloud.client.account.UserAccountManagerImpl import com.nextcloud.client.core.ClockImpl import com.nextcloud.client.network.ConnectivityServiceImpl.GetRequestBuilder @@ -21,7 +20,6 @@ import org.junit.Test class ConnectivityServiceImplIT : AbstractOnServerIT() { @Test fun testInternetWalled() { - val connectivityManager = targetContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val accountManager = targetContext.getSystemService(Context.ACCOUNT_SERVICE) as AccountManager val userAccountManager = UserAccountManagerImpl(targetContext, accountManager) val clientFactory = ClientFactoryImpl(targetContext) @@ -29,7 +27,7 @@ class ConnectivityServiceImplIT : AbstractOnServerIT() { val walledCheckCache = WalledCheckCache(ClockImpl()) val sut = ConnectivityServiceImpl( - connectivityManager, + targetContext, userAccountManager, clientFactory, requestBuilder, diff --git a/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt b/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt index 560b94ff6172..d9efbb954040 100644 --- a/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt +++ b/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt @@ -8,16 +8,16 @@ package com.nextcloud.test import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.network.NetworkChangeListener /** A mocked connectivity service returning that the device is offline **/ class ConnectivityServiceOfflineMock : ConnectivityService { + override fun addListener(listener: NetworkChangeListener) = Unit + override fun removeListener(listener: NetworkChangeListener) = Unit override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) { callback.onComplete(false) } - override fun isConnected(): Boolean = false - override fun isInternetWalled(): Boolean = false - override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI } diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index ac58ccc05fc9..460457289e42 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -32,6 +32,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.Connectivity; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.NetworkChangeListener; import com.nextcloud.client.preferences.AppPreferencesImpl; import com.nextcloud.client.preferences.DarkMode; import com.nextcloud.common.NextcloudClient; @@ -371,6 +372,16 @@ public void uploadFile(File file, String remotePath) { public void uploadOCUpload(OCUpload ocUpload) { ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void addListener(@NonNull NetworkChangeListener listener) { + + } + + @Override + public void removeListener(@NonNull NetworkChangeListener listener) { + + } + @Override public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java index 1b0e1b8d3c17..2d6031790c93 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractOnServerIT.java @@ -21,6 +21,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.Connectivity; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.NetworkChangeListener; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.UploadsStorageManager; import com.owncloud.android.db.OCUpload; @@ -203,6 +204,16 @@ public void uploadOCUpload(OCUpload ocUpload) { public void uploadOCUpload(OCUpload ocUpload, int localBehaviour) { ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void addListener(@NonNull NetworkChangeListener listener) { + + } + + @Override + public void removeListener(@NonNull NetworkChangeListener listener) { + + } + @Override public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { diff --git a/app/src/androidTest/java/com/owncloud/android/UploadIT.java b/app/src/androidTest/java/com/owncloud/android/UploadIT.java index 8072bb5c1805..e8a9e442e8a3 100644 --- a/app/src/androidTest/java/com/owncloud/android/UploadIT.java +++ b/app/src/androidTest/java/com/owncloud/android/UploadIT.java @@ -14,6 +14,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.Connectivity; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.NetworkChangeListener; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.UploadsStorageManager; import com.owncloud.android.db.OCUpload; @@ -56,6 +57,16 @@ public class UploadIT extends AbstractOnServerIT { targetContext.getContentResolver()); private ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void addListener(@NonNull NetworkChangeListener listener) { + + } + + @Override + public void removeListener(@NonNull NetworkChangeListener listener) { + + } + @Override public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { @@ -268,6 +279,16 @@ public BatteryStatus getBattery() { @Test public void testUploadOnWifiOnlyButNoWifi() { ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void addListener(@NonNull NetworkChangeListener listener) { + + } + + @Override + public void removeListener(@NonNull NetworkChangeListener listener) { + + } + @Override public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { @@ -285,7 +306,7 @@ public boolean isInternetWalled() { @Override public Connectivity getConnectivity() { - return new Connectivity(true, false, false, true); + return new Connectivity(true, false, false, true, false); } }; OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", @@ -357,6 +378,16 @@ public void testUploadOnWifiOnlyAndWifi() { @Test public void testUploadOnWifiOnlyButMeteredWifi() { ConnectivityService connectivityServiceMock = new ConnectivityService() { + @Override + public void addListener(@NonNull NetworkChangeListener listener) { + + } + + @Override + public void removeListener(@NonNull NetworkChangeListener listener) { + + } + @Override public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { @@ -374,7 +405,7 @@ public boolean isInternetWalled() { @Override public Connectivity getConnectivity() { - return new Connectivity(true, true, true, true); + return new Connectivity(true, true, true, true, false); } }; OCUpload ocUpload = new OCUpload(FileStorageUtils.getTemporalPath(account.name) + "/empty.txt", diff --git a/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt index 00c568d506ad..874025818bba 100644 --- a/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt @@ -16,6 +16,7 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.network.NetworkChangeListener import com.owncloud.android.AbstractOnServerIT import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.UploadsStorageManager @@ -34,10 +35,10 @@ abstract class FileUploaderIT : AbstractOnServerIT() { private var uploadsStorageManager: UploadsStorageManager? = null private val connectivityServiceMock: ConnectivityService = object : ConnectivityService { + override fun addListener(listener: NetworkChangeListener) = Unit + override fun removeListener(listener: NetworkChangeListener) = Unit override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) = Unit - override fun isConnected(): Boolean = false - override fun isInternetWalled(): Boolean = false override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI } diff --git a/app/src/debug/java/com/nextcloud/test/TestActivity.kt b/app/src/debug/java/com/nextcloud/test/TestActivity.kt index 5bbbf0258550..5f5bc6fd0c18 100644 --- a/app/src/debug/java/com/nextcloud/test/TestActivity.kt +++ b/app/src/debug/java/com/nextcloud/test/TestActivity.kt @@ -16,6 +16,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.network.NetworkChangeListener import com.nextcloud.utils.EditorUtils import com.owncloud.android.R import com.owncloud.android.databinding.TestLayoutBinding @@ -43,12 +44,11 @@ class TestActivity : private lateinit var binding: TestLayoutBinding val connectivityServiceMock: ConnectivityService = object : ConnectivityService { + override fun addListener(listener: NetworkChangeListener) = Unit + override fun removeListener(listener: NetworkChangeListener) = Unit override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) = Unit - override fun isConnected(): Boolean = false - override fun isInternetWalled(): Boolean = false - override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI } diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index c2e55a52afa1..1ce60564884f 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -26,7 +26,6 @@ import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity; import com.nextcloud.client.widget.DashboardWidgetProvider; import com.nextcloud.client.widget.DashboardWidgetService; -import com.nextcloud.receiver.NetworkChangeReceiver; import com.nextcloud.ui.ChooseAccountDialogFragment; import com.nextcloud.ui.ChooseStorageLocationDialogFragment; import com.nextcloud.ui.ImageDetailFragment; @@ -324,9 +323,6 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract BootupBroadcastReceiver bootupBroadcastReceiver(); - @ContributesAndroidInjector - abstract NetworkChangeReceiver networkChangeReceiver(); - @ContributesAndroidInjector abstract NotificationWork.NotificationReceiver notificationWorkBroadcastReceiver(); diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index b0ec486822e3..b57f29b1c836 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -648,7 +648,6 @@ class FileUploadHelper { files: List, accountName: String ): Pair, List> { - val autoUploadFolders = mutableListOf() val nonAutoUploadFiles = mutableListOf() diff --git a/app/src/main/java/com/nextcloud/client/network/Connectivity.kt b/app/src/main/java/com/nextcloud/client/network/Connectivity.kt index e3b4b197c3d3..e19580ed617c 100644 --- a/app/src/main/java/com/nextcloud/client/network/Connectivity.kt +++ b/app/src/main/java/com/nextcloud/client/network/Connectivity.kt @@ -10,7 +10,8 @@ data class Connectivity( val isConnected: Boolean = false, val isMetered: Boolean = false, val isWifi: Boolean = false, - val isServerAvailable: Boolean? = null + val isServerAvailable: Boolean? = null, + val isVPN: Boolean = false ) { companion object { @JvmField @@ -21,7 +22,8 @@ data class Connectivity( isConnected = true, isMetered = false, isWifi = true, - isServerAvailable = true + isServerAvailable = true, + isVPN = false ) } } diff --git a/app/src/main/java/com/nextcloud/client/network/ConnectivityKey.kt b/app/src/main/java/com/nextcloud/client/network/ConnectivityKey.kt new file mode 100644 index 000000000000..20c62e040fb2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/ConnectivityKey.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.network + +import com.nextcloud.client.account.UserAccountManager + +data class ConnectivityKey(val accountName: String, val baseUrl: String) { + companion object { + fun getBy(accountManager: UserAccountManager): ConnectivityKey = ConnectivityKey( + accountManager.user.accountName, + accountManager.user.server.uri.toString() + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java b/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java index 7da4afe6ea85..9329dc284739 100644 --- a/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java +++ b/app/src/main/java/com/nextcloud/client/network/ConnectivityService.java @@ -7,6 +7,8 @@ package com.nextcloud.client.network; +import android.net.ConnectivityManager; +import android.net.Network; import androidx.annotation.NonNull; /** @@ -14,32 +16,71 @@ * and server reachability. */ public interface ConnectivityService { + void addListener(@NonNull NetworkChangeListener listener); + void removeListener(@NonNull NetworkChangeListener listener); + /** - * Checks the availability of the server and the device's internet connection. - *

- * This method performs a network request to verify if the server is accessible and - * checks if the device has an active internet connection. - *

+ * Asynchronously checks whether both the device's network connection + * and the Nextcloud server are available. + * + *

This method executes its logic on a background thread and posts the result + * back to the main thread through the provided {@link GenericCallback}.

* - * @param callback A callback to handle the result of the network and server availability check. + *

The check is based on {@link #isInternetWalled()} — if the Internet is not + * walled (i.e., the server is reachable and not restricted by a captive portal), + * this method reports {@code true}. Otherwise, it reports {@code false}.

+ * + * @param callback a callback that receives {@code true} when the network and + * Nextcloud server are reachable, or {@code false} otherwise. */ void isNetworkAndServerAvailable(@NonNull GenericCallback callback); + /** + * Checks whether the device currently has an active, validated Internet connection + * via a recognized transport type. + * + *

This method queries the Android {@link ConnectivityManager} to determine + * whether there is an active {@link Network} with Internet capability and an + * acceptable transport such as Wi-Fi, Cellular, Ethernet, VPN, or Bluetooth.

+ * + *

For Android 12 (API 31) and newer, USB network transport is also considered valid.

+ * + *

Note: This only confirms that the Android system has validated Internet access, + * not necessarily that the Nextcloud server itself is reachable.

+ * + * @return {@code true} if the device is connected to the Internet through a supported + * transport type; {@code false} otherwise. + */ boolean isConnected(); /** - * Check if server is accessible by issuing HTTP status check request. - * Since this call involves network traffic, it should not be called - * on a main thread. + * Determines whether the device's current Internet connection is "walled" — that is, + * restricted by a captive portal or other form of network access control that prevents + * full connectivity to the Nextcloud server. + * + *

This method does not test general Internet reachability (e.g. Google or DNS), + * but rather focuses on the ability to access the configured Nextcloud server directly. + * In other words, it checks whether the server can be reached without network interference + * such as a hotel's captive portal, Wi-Fi login page, or similar restrictions.

* - * @return True if server is unreachable, false otherwise + *

Results are cached for subsequent checks to minimize unnecessary HTTP requests.

+ * + * @return {@code true} if the Internet appears to be walled (e.g. captive portal or + * restricted access); {@code false} if the Nextcloud server is reachable and + * the network allows normal Internet access. */ boolean isInternetWalled(); /** - * Get current network connectivity status. + * Returns a {@link Connectivity} object that represents the current network state. + * + *

This includes whether the device is connected, whether the network is metered, + * and whether it uses Wi-Fi or Ethernet transport. It uses + * {@link #isConnected()} to verify active Internet capability

+ * + *

If no active network is found, {@link Connectivity#DISCONNECTED} is returned.

* - * @return Network connectivity status in platform-agnostic format + * @return a {@link Connectivity} instance describing the current network connection. */ Connectivity getConnectivity(); diff --git a/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.java b/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.java deleted file mode 100644 index ad6f07a0456b..000000000000 --- a/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author Chris Narkiewicz - * Copyright (C) 2021 Chris Narkiewicz - * - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.nextcloud.client.network; - -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; - -import com.nextcloud.client.account.Server; -import com.nextcloud.client.account.UserAccountManager; -import com.nextcloud.common.PlainClient; -import com.nextcloud.operations.GetMethod; -import com.owncloud.android.lib.common.utils.Log_OC; - -import org.apache.commons.httpclient.HttpStatus; - -import androidx.annotation.NonNull; -import androidx.core.net.ConnectivityManagerCompat; -import kotlin.jvm.functions.Function1; - -class ConnectivityServiceImpl implements ConnectivityService { - - private static final String TAG = "ConnectivityServiceImpl"; - private static final String CONNECTIVITY_CHECK_ROUTE = "/index.php/204"; - - private final ConnectivityManager platformConnectivityManager; - private final UserAccountManager accountManager; - private final ClientFactory clientFactory; - private final GetRequestBuilder requestBuilder; - private final WalledCheckCache walledCheckCache; - private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); - - static class GetRequestBuilder implements Function1 { - @Override - public GetMethod invoke(String url) { - return new GetMethod(url, false); - } - } - - ConnectivityServiceImpl(ConnectivityManager platformConnectivityManager, - UserAccountManager accountManager, - ClientFactory clientFactory, - GetRequestBuilder requestBuilder, - final WalledCheckCache walledCheckCache) { - this.platformConnectivityManager = platformConnectivityManager; - this.accountManager = accountManager; - this.clientFactory = clientFactory; - this.requestBuilder = requestBuilder; - this.walledCheckCache = walledCheckCache; - } - - @Override - public void isNetworkAndServerAvailable(@NonNull GenericCallback callback) { - new Thread(() -> { - Network activeNetwork = platformConnectivityManager.getActiveNetwork(); - NetworkCapabilities networkCapabilities = platformConnectivityManager.getNetworkCapabilities(activeNetwork); - boolean hasInternet = networkCapabilities != null && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); - - boolean result; - if (hasInternet) { - result = !isInternetWalled(); - } else { - Log_OC.e(TAG, "network and server not available"); - result = false; - } - - mainThreadHandler.post(() -> callback.onComplete(result)); - }).start(); - } - - @Override - public boolean isConnected() { - Network nw = platformConnectivityManager.getActiveNetwork(); - NetworkCapabilities actNw = platformConnectivityManager.getNetworkCapabilities(nw); - - if (actNw == null) { - Log_OC.e(TAG, "network capabilities is null"); - return false; - } - - if (actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || - actNw.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - actNw.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) || - actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) { - return true; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - actNw.hasTransport(NetworkCapabilities.TRANSPORT_USB)) { - return true; - } - - Log_OC.e(TAG, "network is not connected"); - return false; - } - - @Override - public boolean isInternetWalled() { - final Boolean cachedValue = walledCheckCache.getValue(); - if (cachedValue != null) { - if (cachedValue) { - Log_OC.e(TAG, "network is walled, cached value is used"); - } - - return cachedValue; - } else { - Server server = accountManager.getUser().getServer(); - String baseServerAddress = server.getUri().toString(); - - boolean result; - Connectivity c = getConnectivity(); - if (c != null && c.isConnected() && c.isWifi() && !c.isMetered() && !baseServerAddress.isEmpty()) { - Log_OC.d(TAG, "checking network status"); - - GetMethod get = requestBuilder.invoke(baseServerAddress + CONNECTIVITY_CHECK_ROUTE); - PlainClient client = clientFactory.createPlainClient(); - - int status = get.execute(client); - - // Content-Length is not available when using chunked transfer encoding, so check for -1 as well - result = !(status == HttpStatus.SC_NO_CONTENT && get.getResponseContentLength() <= 0); - get.releaseConnection(); - if (result) { - Log_OC.w(TAG, "isInternetWalled(): Failed to GET " + CONNECTIVITY_CHECK_ROUTE + "," + - " assuming connectivity is impaired"); - } - } else { - Log_OC.e(TAG, "cannot check network status, connectivity is not eligible"); - - if (c != null) { - if (c.isMetered()) { - Log_OC.e(TAG, "network is metered"); - } - - if (!c.isWifi()) { - Log_OC.e(TAG, "network is not connected to wi-fi"); - } - - if (!c.isConnected()) { - Log_OC.e(TAG, "network is not connected"); - } - } - - result = (c != null && !c.isConnected()); - } - - if (result) { - Log_OC.e(TAG, "network is walled"); - } - - walledCheckCache.setValue(result); - return result; - } - } - - @Override - public Connectivity getConnectivity() { - NetworkInfo networkInfo; - try { - networkInfo = platformConnectivityManager.getActiveNetworkInfo(); - } catch (Throwable t) { - Log_OC.e(TAG, "no network available or no information: ", t); - networkInfo = null; - } - - if (networkInfo != null) { - boolean isConnected = networkInfo.isConnectedOrConnecting(); - // more detailed check - boolean isMetered; - isMetered = isNetworkMetered(); - boolean isWifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI || hasNonCellularConnectivity(); - - if (isMetered) { - Log_OC.w(TAG, "getConnectivity(): network is metered"); - } - - if (!isWifi) { - Log_OC.w(TAG, "getConnectivity(): network is not wi-fi"); - } - - if (!isConnected) { - Log_OC.e(TAG, "getConnectivity(): network is not connected"); - } - - return new Connectivity(isConnected, isMetered, isWifi, null); - } else { - return Connectivity.DISCONNECTED; - } - } - - private boolean isNetworkMetered() { - final Network network = platformConnectivityManager.getActiveNetwork(); - try { - NetworkCapabilities networkCapabilities = platformConnectivityManager.getNetworkCapabilities(network); - if (networkCapabilities != null) { - return !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED); - } else { - return ConnectivityManagerCompat.isActiveNetworkMetered(platformConnectivityManager); - } - } catch (RuntimeException e) { - Log_OC.e(TAG, "Exception when checking network capabilities", e); - return false; - } - } - - private boolean hasNonCellularConnectivity() { - for (NetworkInfo networkInfo : platformConnectivityManager.getAllNetworkInfo()) { - if (networkInfo.isConnectedOrConnecting() && (networkInfo.getType() == ConnectivityManager.TYPE_WIFI || - networkInfo.getType() == ConnectivityManager.TYPE_ETHERNET)) { - return true; - } - } - return false; - } -} diff --git a/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.kt b/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.kt new file mode 100644 index 000000000000..2fd60a5171b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/ConnectivityServiceImpl.kt @@ -0,0 +1,243 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ConnectivityService.GenericCallback +import com.nextcloud.operations.GetMethod +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.commons.httpclient.HttpStatus +import kotlin.jvm.functions.Function1 + +@Suppress("TooGenericExceptionCaught", "ReturnCount") +class ConnectivityServiceImpl( + context: Context, + private val accountManager: UserAccountManager, + private val clientFactory: ClientFactory, + private val requestBuilder: GetRequestBuilder, + private val walledCheckCache: WalledCheckCache +) : ConnectivityService { + private val scope = CoroutineScope(Dispatchers.IO) + private var availabilityCheckJob: Job? = null + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val listeners = mutableSetOf() + private val key: ConnectivityKey + get() = ConnectivityKey.getBy(accountManager) + + override fun addListener(listener: NetworkChangeListener) { + listeners.add(listener) + } + + override fun removeListener(listener: NetworkChangeListener) { + listeners.remove(listener) + } + + private fun notifyListeners() { + scope.launch { + val available = !isInternetWalled() + withContext(Dispatchers.Main) { + listeners.forEach { + Log_OC.d(TAG, "notifying listeners") + it.networkAndServerConnectionListener(available) + } + } + } + } + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onLost(network: Network) { + Log_OC.w(TAG, "connection lost") + updateConnectivity() + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + Log_OC.d(TAG, "capability changed") + updateConnectivity() + } + } + + class GetRequestBuilder : Function1 { + override fun invoke(url: String) = GetMethod(url, false) + } + + init { + connectivityManager.registerDefaultNetworkCallback(networkCallback) + updateConnectivity() + Log_OC.d(TAG, "connectivity service constructed") + } + + fun updateConnectivity() { + val currentKey = key + val previous = walledCheckCache.getConnectivity(currentKey) ?: Connectivity.DISCONNECTED + + val capabilities = connectivityManager.activeNetwork + ?.let { connectivityManager.getNetworkCapabilities(it) } + + val newConnectivity = if (capabilities == null) { + Log_OC.w(TAG, "no active network or capabilities, connectivity is disconnected") + Connectivity.DISCONNECTED + } else { + val hasTransport = isSupportedTransport(capabilities) + val hasInternetCapability = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + + Connectivity( + isConnected = hasTransport || hasInternetCapability, + isMetered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED), + isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET), + isServerAvailable = previous.isServerAvailable, + isVPN = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + ) + } + + if (previous != newConnectivity) { + walledCheckCache.putConnectivityValue(currentKey, newConnectivity) + + // only clear server cache if structural change happened + val isStructural = ( + previous.isConnected != newConnectivity.isConnected || + previous.isWifi != newConnectivity.isWifi + ) + + if (isStructural) { + walledCheckCache.clear(currentKey) + } + notifyListeners() + } + } + + private fun isSupportedTransport(capabilities: NetworkCapabilities) = + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE) || + ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_USB) + ) + + override fun isNetworkAndServerAvailable(callback: GenericCallback) { + availabilityCheckJob?.cancel() + availabilityCheckJob = scope.launch { + val available = !isInternetWalled() + Log_OC.d(TAG, "isNetworkAndServerAvailable: $available") + withContext(Dispatchers.Main) { + callback.onComplete(available) + } + } + } + + override fun isConnected() = + walledCheckCache.getConnectivity(key)?.isConnected ?: Connectivity.DISCONNECTED.isConnected + + override fun isInternetWalled(): Boolean { + val currentKey = key + val cachedValue = walledCheckCache.getValue(currentKey) + if (cachedValue != null) { + Log_OC.d(TAG, "cached value is used, isWalled: $cachedValue") + return cachedValue + } + + val baseServerAddress = accountManager.user.server.uri.toString() + if (baseServerAddress.isEmpty()) { + Log_OC.e(TAG, "no base server address, internet is walled") + return true + } + + val activeCapabilities = connectivityManager.activeNetwork + ?.let { connectivityManager.getNetworkCapabilities(it) } + + if (activeCapabilities == null) { + Log_OC.e(TAG, "no active network capabilities at check time, treating as walled") + return true + } + + val hasLiveTransport = isSupportedTransport(activeCapabilities) + if (!hasLiveTransport) { + Log_OC.e(TAG, "no supported transport at check time, treating as walled") + return true + } + + val currentConnectivity = + walledCheckCache.getConnectivity(key) ?: Connectivity.DISCONNECTED + + val isMeteredNonWifi = !currentConnectivity.isWifi && currentConnectivity.isMetered + if (isMeteredNonWifi) { + Log_OC.w(TAG, "skipping server reachability check, internet is metered and not Wi-Fi") + return false + } + + val get = requestBuilder.invoke(baseServerAddress + CONNECTIVITY_CHECK_ROUTE) + val client = clientFactory.createPlainClient() + + val isWalled = try { + val status = get.execute(client) + (!(status == HttpStatus.SC_NO_CONTENT && get.getResponseContentLength() <= 0)).also { + if (it) Log_OC.w(TAG, "server returned unexpected response") + } + } catch (e: Exception) { + Log_OC.e(TAG, "exception during server check", e) + getWalledValueFromException(e) + } finally { + get.releaseConnection() + } + + walledCheckCache.setValue(currentKey, isWalled) + Log_OC.d(TAG, "server check, isWalled: $isWalled") + return isWalled + } + + override fun getConnectivity() = walledCheckCache.getConnectivity(key) ?: Connectivity.DISCONNECTED + + private fun getWalledValueFromException(e: Exception): Boolean = when (e) { + is java.net.UnknownHostException -> { + Log_OC.w(TAG, "UnknownHostException") + false + } + + is javax.net.ssl.SSLException -> { + Log_OC.w(TAG, "SSLException") + false + } + + is java.net.SocketTimeoutException -> { + Log_OC.w(TAG, "SocketTimeoutException") + false + } + + is java.io.IOException -> { + Log_OC.w(TAG, "IOException") + false + } + + else -> { + Log_OC.w(TAG, "Unknown error, fallback to walled assumption") + true + } + } + + fun unregisterCallback() { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + + companion object { + private const val TAG = "ConnectivityServiceImpl" + private const val CONNECTIVITY_CHECK_ROUTE = "/index.php/204" + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/NetworkChangeListener.kt b/app/src/main/java/com/nextcloud/client/network/NetworkChangeListener.kt new file mode 100644 index 000000000000..572fd87ca9b2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/NetworkChangeListener.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.network + +interface NetworkChangeListener { + fun networkAndServerConnectionListener(isNetworkAndServerAvailable: Boolean) +} diff --git a/app/src/main/java/com/nextcloud/client/network/NetworkModule.java b/app/src/main/java/com/nextcloud/client/network/NetworkModule.java index 8fbca7e25162..fb36c433ced3 100644 --- a/app/src/main/java/com/nextcloud/client/network/NetworkModule.java +++ b/app/src/main/java/com/nextcloud/client/network/NetworkModule.java @@ -4,11 +4,9 @@ * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ - package com.nextcloud.client.network; import android.content.Context; -import android.net.ConnectivityManager; import com.nextcloud.client.account.UserAccountManager; @@ -21,11 +19,11 @@ public class NetworkModule { @Provides - ConnectivityService connectivityService(ConnectivityManager connectivityManager, + ConnectivityService connectivityService(Context context, UserAccountManager accountManager, ClientFactory clientFactory, WalledCheckCache walledCheckCache) { - return new ConnectivityServiceImpl(connectivityManager, + return new ConnectivityServiceImpl(context, accountManager, clientFactory, new ConnectivityServiceImpl.GetRequestBuilder(), @@ -38,10 +36,4 @@ ConnectivityService connectivityService(ConnectivityManager connectivityManager, ClientFactory clientFactory(Context context) { return new ClientFactoryImpl(context); } - - @Provides - @Singleton - ConnectivityManager connectivityManager(Context context) { - return (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); - } } diff --git a/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt b/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt index 39599ab9f65b..bea36139774c 100644 --- a/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt +++ b/app/src/main/java/com/nextcloud/client/network/WalledCheckCache.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -13,37 +14,35 @@ import javax.inject.Singleton @Singleton class WalledCheckCache @Inject constructor(private val clock: Clock) { - - private var cachedEntry: Pair? = null + private var connectivityCache = mutableMapOf() + private val walledStatusCache = mutableMapOf>() @Synchronized - fun isExpired(): Boolean = when (val timestamp = cachedEntry?.first) { - null -> true - - else -> { - val diff = clock.currentTime - timestamp - diff >= CACHE_TIME_MS - } + fun setValue(key: ConnectivityKey, isWalled: Boolean) { + walledStatusCache[key] = Pair(clock.currentTime, isWalled) } @Synchronized - fun setValue(isWalled: Boolean) { - this.cachedEntry = Pair(clock.currentTime, isWalled) + fun clear(key: ConnectivityKey) { + walledStatusCache.remove(key) } @Synchronized - fun getValue(): Boolean? = when (isExpired()) { - true -> null - else -> cachedEntry?.second + fun getValue(key: ConnectivityKey): Boolean? { + val entry = walledStatusCache[key] ?: return null + val isExpired = (clock.currentTime - entry.first) >= CACHE_TIME_MS + return if (isExpired) null else entry.second } @Synchronized - fun clear() { - cachedEntry = null + fun putConnectivityValue(key: ConnectivityKey, connectivity: Connectivity) { + connectivityCache[key] = connectivity } + @Synchronized + fun getConnectivity(key: ConnectivityKey): Connectivity? = connectivityCache[key] + companion object { - // 10 minutes private const val CACHE_TIME_MS = 10 * 60 * 1000 } } diff --git a/app/src/main/java/com/nextcloud/receiver/NetworkChangeReceiver.kt b/app/src/main/java/com/nextcloud/receiver/NetworkChangeReceiver.kt deleted file mode 100644 index d9d7cbea18ef..000000000000 --- a/app/src/main/java/com/nextcloud/receiver/NetworkChangeReceiver.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2024 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.nextcloud.client.network.ConnectivityService - -interface NetworkChangeListener { - fun networkAndServerConnectionListener(isNetworkAndServerAvailable: Boolean) -} - -class NetworkChangeReceiver( - private val listener: NetworkChangeListener, - private val connectivityService: ConnectivityService -) : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent?) { - connectivityService.isNetworkAndServerAvailable { - listener.networkAndServerConnectionListener(it) - } - } -} diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index bea262738e5b..f08bde45fee9 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -52,13 +52,12 @@ import com.nextcloud.client.logger.Logger; import com.nextcloud.client.migrations.MigrationsManager; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.NetworkChangeListener; import com.nextcloud.client.network.WalledCheckCache; import com.nextcloud.client.onboarding.OnboardingService; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.preferences.AppPreferencesImpl; import com.nextcloud.client.preferences.DarkMode; -import com.nextcloud.receiver.NetworkChangeListener; -import com.nextcloud.receiver.NetworkChangeReceiver; import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias; import com.nextcloud.utils.extensions.ContextExtensionsKt; import com.nextcloud.utils.mdm.MDMConfig; @@ -205,8 +204,6 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC private static AppComponent appComponent; - private NetworkChangeReceiver networkChangeReceiver; - /** * Temporary hack */ @@ -230,11 +227,6 @@ public PowerManagementService getPowerManagementService() { return powerManagementService; } - private void registerNetworkChangeReceiver() { - IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); - registerReceiver(networkChangeReceiver, filter); - } - private String getAppProcessName() { return Application.getProcessName(); } @@ -371,12 +363,10 @@ public void onCreate() { } registerGlobalPassCodeProtection(); - networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService); - registerNetworkChangeReceiver(); - if (!MDMConfig.INSTANCE.sendFilesSupport(this)) { disableDocumentsStorageProvider(); } + connectivityService.addListener(this); } public void disableDocumentsStorageProvider() { @@ -1034,6 +1024,7 @@ public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailab @Override public void onTerminate() { super.onTerminate(); + connectivityService.removeListener(this); ReceiversHelper.shutdown(); } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java index ec834e983c1d..c17092e9886a 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java @@ -21,10 +21,8 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.pm.PackageManager; -import android.net.ConnectivityManager; import android.net.Uri; import android.os.Bundle; import android.os.Handler; @@ -38,8 +36,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker; import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.network.ConnectivityService; -import com.nextcloud.receiver.NetworkChangeListener; -import com.nextcloud.receiver.NetworkChangeReceiver; +import com.nextcloud.client.network.NetworkChangeListener; import com.nextcloud.utils.EditorUtils; import com.nextcloud.utils.extensions.ActivityExtensionsKt; import com.nextcloud.utils.extensions.BundleExtensionsKt; @@ -191,15 +188,8 @@ public abstract class FileActivity extends DrawerActivity @Inject ArbitraryDataProvider arbitraryDataProvider; - private NetworkChangeReceiver networkChangeReceiver; - private FilesRepository filesRepository; - private void registerNetworkChangeReceiver() { - IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); - registerReceiver(networkChangeReceiver, filter); - } - @Override public void showFiles(boolean onDeviceOnly, boolean personalFiles) { // must be specialized in subclasses @@ -222,7 +212,6 @@ public void showFiles(boolean onDeviceOnly, boolean personalFiles) { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - networkChangeReceiver = new NetworkChangeReceiver(this, connectivityService); usersAndGroupsSearchConfig.reset(); mHandler = new Handler(); mFileOperationsHelper = new FileOperationsHelper(this, getUserAccountManager(), connectivityService, editorUtils); @@ -252,8 +241,6 @@ protected void onCreate(Bundle savedInstanceState) { mOperationsServiceConnection = new OperationsServiceConnection(); bindService(new Intent(this, OperationsService.class), mOperationsServiceConnection, Context.BIND_AUTO_CREATE); - registerNetworkChangeReceiver(); - filesRepository = new RemoteFilesRepository(getClientRepository(), this); } @@ -278,9 +265,16 @@ public void networkAndServerConnectionListener(boolean isNetworkAndServerAvailab @Override protected void onStart() { super.onStart(); + connectivityService.addListener(this); fetchExternalLinks(false); } + @Override + protected void onStop() { + super.onStop(); + connectivityService.removeListener(this); + } + @Override protected void onResume() { super.onResume(); @@ -306,8 +300,6 @@ protected void onDestroy() { mOperationsServiceBinder = null; } - unregisterReceiver(networkChangeReceiver); - super.onDestroy(); } diff --git a/app/src/main/java/com/owncloud/android/utils/ReceiversHelper.java b/app/src/main/java/com/owncloud/android/utils/ReceiversHelper.java index 8dfd2984d979..b3f1778ce40b 100644 --- a/app/src/main/java/com/owncloud/android/utils/ReceiversHelper.java +++ b/app/src/main/java/com/owncloud/android/utils/ReceiversHelper.java @@ -15,6 +15,7 @@ import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.device.PowerManagementService; +import com.nextcloud.client.network.ConnectivityKey; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.network.WalledCheckCache; import com.nextcloud.common.DNSCache; @@ -55,7 +56,7 @@ public static void registerNetworkChangeReceiver(final UploadsStorageManager upl public void onReceive(Context context, Intent intent) { executor.execute(() -> { DNSCache.clear(); - walledCheckCache.clear(); + walledCheckCache.clear(ConnectivityKey.Companion.getBy(accountManager)); Log_OC.d(TAG,"DNS caches are cleared"); }); diff --git a/app/src/test/java/com/nextcloud/client/network/ConnectivityServiceTest.kt b/app/src/test/java/com/nextcloud/client/network/ConnectivityServiceTest.kt index fe11a21d5a10..12e0859cf9e0 100644 --- a/app/src/test/java/com/nextcloud/client/network/ConnectivityServiceTest.kt +++ b/app/src/test/java/com/nextcloud/client/network/ConnectivityServiceTest.kt @@ -1,24 +1,31 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2021 Chris Narkiewicz * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.network +import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities -import android.net.NetworkInfo import com.nextcloud.client.account.Server import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.logger.Logger import com.nextcloud.common.PlainClient import com.nextcloud.operations.GetMethod import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.lib.resources.status.OwnCloudVersion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import org.apache.commons.httpclient.HttpStatus +import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -26,12 +33,12 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Suite -import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq -import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -49,22 +56,14 @@ class ConnectivityServiceTest { internal abstract class Base { companion object { - fun mockNetworkInfo(connected: Boolean, connecting: Boolean, type: Int): NetworkInfo { - val networkInfo = mock() - whenever(networkInfo.isConnectedOrConnecting).thenReturn(connected or connecting) - whenever(networkInfo.isConnected).thenReturn(connected) - whenever(networkInfo.type).thenReturn(type) - return networkInfo - } - const val SERVER_BASE_URL = "https://test.nextcloud.localhost" } @Mock - lateinit var platformConnectivityManager: ConnectivityManager + lateinit var context: Context @Mock - lateinit var networkInfo: NetworkInfo + lateinit var platformConnectivityManager: ConnectivityManager @Mock lateinit var accountManager: UserAccountManager @@ -90,10 +89,7 @@ class ConnectivityServiceTest { @Mock lateinit var networkCapabilities: NetworkCapabilities - @Mock - lateinit var logger: Logger - - val baseServerUri = URI.create(SERVER_BASE_URL) + val baseServerUri: URI = URI.create(SERVER_BASE_URL) val newServer = Server(baseServerUri, NextcloudVersion.nextcloud_31) val legacyServer = Server(baseServerUri, OwnCloudVersion.nextcloud_20) @@ -102,87 +98,115 @@ class ConnectivityServiceTest { lateinit var connectivityService: ConnectivityServiceImpl + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = StandardTestDispatcher() + + @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUpMocks() { - MockitoAnnotations.initMocks(this) - connectivityService = ConnectivityServiceImpl( - platformConnectivityManager, - accountManager, - clientFactory, - requestBuilder, - walledCheckCache - ) + Dispatchers.setMain(testDispatcher) + MockitoAnnotations.openMocks(this) + + whenever(context.getSystemService(Context.CONNECTIVITY_SERVICE)) + .thenReturn(platformConnectivityManager) - whenever(networkCapabilities.hasCapability(eq(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED))) - .thenReturn(true) whenever(platformConnectivityManager.activeNetwork).thenReturn(network) - whenever(platformConnectivityManager.activeNetworkInfo).thenReturn(networkInfo) - whenever(platformConnectivityManager.allNetworkInfo).thenReturn(arrayOf(networkInfo)) - whenever(platformConnectivityManager.getNetworkCapabilities(any())).thenReturn(networkCapabilities) + whenever(platformConnectivityManager.getNetworkCapabilities(network)) + .thenReturn(networkCapabilities) + + whenever(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) + .thenReturn(true) + whenever( + networkCapabilities + .hasCapability(eq(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) + ) + .thenReturn(true) + whenever(requestBuilder.invoke(any())).thenReturn(getRequest) whenever(clientFactory.createPlainClient()).thenReturn(client) whenever(user.server).thenReturn(newServer) whenever(accountManager.user).thenReturn(user) - whenever(walledCheckCache.getValue()).thenReturn(null) + + val key = ConnectivityKey(user.accountName,newServer.uri.toString()) + whenever(walledCheckCache.getValue(key)).thenReturn(null) + + connectivityService = ConnectivityServiceImpl( + context, + accountManager, + clientFactory, + requestBuilder, + walledCheckCache + ) } } + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + internal class Disconnected : Base() { @Test - fun `wifi is disconnected`() { - whenever(networkInfo.isConnectedOrConnecting).thenReturn(false) - whenever(networkInfo.type).thenReturn(ConnectivityManager.TYPE_WIFI) - connectivityService.connectivity.apply { - assertFalse(isConnected) - assertTrue(isWifi) - } + fun `no active network`() { + // GIVEN + whenever(platformConnectivityManager.activeNetwork).thenReturn(null) + // WHEN + connectivityService.updateConnectivity() + // THEN + assertSame(Connectivity.DISCONNECTED, connectivityService.connectivity) + assertFalse(connectivityService.isConnected) } @Test - fun `no active network`() { - whenever(platformConnectivityManager.activeNetworkInfo).thenReturn(null) + fun `no network capabilities`() { + // GIVEN + whenever(platformConnectivityManager.getNetworkCapabilities(network)).thenReturn(null) + // WHEN + connectivityService.updateConnectivity() + // THEN assertSame(Connectivity.DISCONNECTED, connectivityService.connectivity) + assertFalse(connectivityService.isConnected) } } internal class IsConnected : Base() { - @Test fun `connected to wifi`() { - whenever(networkInfo.isConnectedOrConnecting).thenReturn(true) - whenever(networkInfo.type).thenReturn(ConnectivityManager.TYPE_WIFI) + // GIVEN: Default setup is connected Wi-Fi + // WHEN + connectivityService.updateConnectivity() + // THEN assertTrue(connectivityService.connectivity.isConnected) assertTrue(connectivityService.connectivity.isWifi) } @Test - fun `connected to wifi and vpn`() { - whenever(networkInfo.isConnectedOrConnecting).thenReturn(true) - whenever(networkInfo.type).thenReturn(ConnectivityManager.TYPE_VPN) - val wifiNetworkInfoList = arrayOf( - mockNetworkInfo( - connected = true, - connecting = true, - type = ConnectivityManager.TYPE_VPN - ), - mockNetworkInfo( - connected = true, - connecting = true, - type = ConnectivityManager.TYPE_WIFI - ) - ) - whenever(platformConnectivityManager.allNetworkInfo).thenReturn(wifiNetworkInfoList) + fun `connected to mobile network`() { + // GIVEN + whenever(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) + .thenReturn(false) + whenever(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) + .thenReturn(true) + // WHEN + connectivityService.updateConnectivity() + // THEN connectivityService.connectivity.let { assertTrue(it.isConnected) - assertTrue(it.isWifi) + assertFalse(it.isWifi) } } @Test - fun `connected to mobile network`() { - whenever(networkInfo.isConnectedOrConnecting).thenReturn(true) - whenever(networkInfo.type).thenReturn(ConnectivityManager.TYPE_MOBILE) - whenever(platformConnectivityManager.allNetworkInfo).thenReturn(arrayOf(networkInfo)) + fun `connected to vpn`() { + // GIVEN + whenever(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) + .thenReturn(false) + whenever(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) + .thenReturn(true) + // WHEN + connectivityService.updateConnectivity() + // THEN connectivityService.connectivity.let { assertTrue(it.isConnected) assertFalse(it.isWifi) @@ -191,12 +215,10 @@ class ConnectivityServiceTest { } internal class WifiConnectionWalledStatusOnLegacyServer : Base() { - @Before fun setUp() { - whenever(networkInfo.isConnectedOrConnecting).thenReturn(true) - whenever(networkInfo.type).thenReturn(ConnectivityManager.TYPE_WIFI) whenever(user.server).thenReturn(legacyServer) + connectivityService.updateConnectivity() assertTrue( "Precondition failed", connectivityService.connectivity.let { @@ -206,7 +228,7 @@ class ConnectivityServiceTest { } fun mockResponse(maintenance: Boolean = true, httpStatus: Int = HttpStatus.SC_OK) { - whenever(client.execute(getRequest)).thenReturn(httpStatus) + whenever(getRequest.execute(client)).thenReturn(httpStatus) val body = """{"maintenance":$maintenance}""" whenever(getRequest.getResponseContentLength()).thenReturn(body.length.toLong()) @@ -225,23 +247,17 @@ class ConnectivityServiceTest { assertTrue(connectivityService.isInternetWalled) } - @Test - fun `status endpoint is used to determine internet state`() { - mockResponse() - connectivityService.isInternetWalled - val urlCaptor = ArgumentCaptor.forClass(String::class.java) - verify(requestBuilder).invoke(urlCaptor.capture()) - assertTrue("Invalid URL used to check status", urlCaptor.value.endsWith("/204")) - } + // `status endpoint is used to determine internet state` removed: the new impl + // uses /index.php/204 for all server versions, which is already covered by + // WifiConnectionWalledStatus.`index endpoint is used to determine internet state`. } internal class WifiConnectionWalledStatus : Base() { - @Before fun setUp() { - whenever(networkInfo.isConnectedOrConnecting).thenReturn(true) - whenever(networkInfo.type).thenReturn(ConnectivityManager.TYPE_WIFI) - whenever(accountManager.getServerVersion(any())).thenReturn(OwnCloudVersion.nextcloud_20) + connectivityService.updateConnectivity() + Thread.sleep(200) + clearInvocations(requestBuilder, client, getRequest) connectivityService.connectivity.let { assertTrue(it.isConnected) assertTrue(it.isWifi) @@ -253,8 +269,9 @@ class ConnectivityServiceTest { fun `request not sent when not connected`() { // GIVEN // network is not connected - whenever(networkInfo.isConnectedOrConnecting).thenReturn(false) - whenever(networkInfo.isConnected).thenReturn(false) + whenever(platformConnectivityManager.activeNetwork).thenReturn(null) + connectivityService.updateConnectivity() + assertFalse("Precondition failed", connectivityService.isConnected) // WHEN // connectivity is checked @@ -263,23 +280,29 @@ class ConnectivityServiceTest { // THEN // connection is walled // request is not sent - assertTrue("Server should not be accessible", result) + assertTrue("Should be walled if not connected", result) verify(requestBuilder, never()).invoke(any()) verify(client, never()).execute(any()) } @Test - fun `request not sent when wifi is metered`() { + fun `request IS sent when wifi is metered`() { // GIVEN - // network is connected to wifi - // wifi is metered - whenever(networkCapabilities.hasCapability(any())).thenReturn(false) // this test is mocked for API M - whenever(platformConnectivityManager.isActiveNetworkMetered).thenReturn(true) + // network is connected to wifi, but metered + whenever( + networkCapabilities + .hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + ) + .thenReturn(false) + connectivityService.updateConnectivity() + connectivityService.connectivity.let { assertTrue("should be connected", it.isConnected) assertTrue("should be connected to wifi", it.isWifi) - assertTrue("check mocking, this check is complicated and depends on SDK version", it.isMetered) + assertTrue("should be metered", it.isMetered) } + // Mock a successful 204 response + mockResponse(contentLength = 0, status = HttpStatus.SC_NO_CONTENT) // WHEN // connectivity is checked @@ -287,34 +310,11 @@ class ConnectivityServiceTest { // THEN // assume internet is not walled - // request is not sent - assertFalse("Server should not be accessible", result) - verify(requestBuilder, never()).invoke(any()) - verify(getRequest, never()).execute(any()) - } - - @Test - fun `check cache value when server uri is not set`() { - // GIVEN - // network connectivity is present - // user has no server URI (empty) - val serverWithoutUri = Server(URI(""), OwnCloudVersion.nextcloud_20) - whenever(user.server).thenReturn(serverWithoutUri) - - // WHEN - // connectivity is checked - val result = connectivityService.isInternetWalled - - // THEN - // connection is walled - // request is not sent - assertFalse("Cached value not set", result) - verify(requestBuilder, never()).invoke(any()) - verify(getRequest, never()).execute(any()) + // request IS sent + assertEquals(false, result) } fun mockResponse(contentLength: Long = 0, status: Int = HttpStatus.SC_OK) { - whenever(client.execute(any())).thenReturn(status) whenever(getRequest.getStatusCode()).thenReturn(status) whenever(getRequest.getResponseContentLength()).thenReturn(contentLength) whenever(getRequest.execute(client)).thenReturn(status) @@ -345,9 +345,12 @@ class ConnectivityServiceTest { fun `index endpoint is used to determine internet state`() { mockResponse() connectivityService.isInternetWalled - val urlCaptor = ArgumentCaptor.forClass(String::class.java) + val urlCaptor = argumentCaptor() verify(requestBuilder).invoke(urlCaptor.capture()) - assertTrue("Invalid URL used to check status", urlCaptor.value.endsWith("/index.php/204")) + assertTrue( + "Invalid URL used to check status", + urlCaptor.firstValue.endsWith("/index.php/204") + ) verify(getRequest, times(1)).execute(client) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9406fa3590c..00b1c78e97a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ junit = "4.13.2" junitVersion = "1.3.0" juniversalchardetVersion = "2.5.0" kotlin = "2.3.10" +kotlinxCoroutinesTestVersion = "1.10.2" kotlinxSerializationJson = "1.10.0" ksp = "2.3.6" leakcanary = "2.14" @@ -99,6 +100,7 @@ core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestVersi document-scanning-android-sdk = { module = "com.github.Hazzatur:Document-Scanning-Android-SDK", version.ref = "documentScannerVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterfaceVersion" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTestVersion" } material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCoreVersion" } webkit = { module = "androidx.webkit:webkit", version.ref = "webkitVersion" } splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splash-screen" }