diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/AccountNameUtils.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/AccountNameUtils.kt new file mode 100644 index 00000000..521b9eae --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/AccountNameUtils.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils + +object AccountNameUtils { + private const val SEPARATOR = '@' + + @JvmStatic + fun build(user: String, server: String): String = "$user$SEPARATOR$server" + + @JvmStatic + fun getUserPart(accountName: String): String = accountName.substringBeforeLast(SEPARATOR) + + @JvmStatic + fun getServerPart(accountName: String): String = accountName.substringAfterLast(SEPARATOR, "") + + @JvmStatic + fun removeScheme(serverUrl: String): String = ServerAddressUtils.removeScheme(serverUrl) + + @JvmStatic + fun isSameServer(firstServer: String, secondServer: String): Boolean = + ServerAddressUtils.toComparable(firstServer) == ServerAddressUtils.toComparable(secondServer) + + @JvmStatic + fun isSameAccount(firstServer: String, firstUserId: String, secondServer: String, secondUserId: String): Boolean = + isSameServer(firstServer, secondServer) && firstUserId.equals(secondUserId, ignoreCase = true) + + @JvmStatic + fun isValid(accountName: String): Boolean { + val user = getUserPart(accountName) + val isValidUser = user.isNotBlank() && user.trim() == user + return isValidUser && ServerAddressUtils.isValid(getServerPart(accountName)) + } +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ServerAddressUtils.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ServerAddressUtils.kt new file mode 100644 index 00000000..3150cccf --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ServerAddressUtils.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils + +import java.net.IDN +import java.util.Locale + +internal object ServerAddressUtils { + private const val SCHEME_SEPARATOR = "://" + private const val PATH_SEPARATOR = '/' + private const val PORT_SEPARATOR = ':' + private const val HOST_LABEL_SEPARATOR = '.' + private const val QUERY_START = '?' + private const val FRAGMENT_START = '#' + private const val IPV6_START = '[' + private const val IPV6_END = ']' + private const val INDEX_PHP_PATH = "/index.php" + private const val HTTP_SCHEME = "http" + private const val HTTP_PORT = "80" + private const val HTTPS_PORT = "443" + private const val MAX_PORT_LENGTH = 5 + + fun removeScheme(serverUrl: String): String = serverUrl.substringAfter(SCHEME_SEPARATOR) + + fun isValid(server: String): Boolean { + if (server.isEmpty() || server.any { it.isForbiddenInServer() }) { + return false + } + + val authority = server.substringBefore(PATH_SEPARATOR) + val host = authority.toHost() + val portPart = authority.removePrefix(host) + val port = portPart.removePrefix(PORT_SEPARATOR.toString()) + val isValidPort = + portPart.isEmpty() || + (portPart.startsWith(PORT_SEPARATOR) && port.length in 1..MAX_PORT_LENGTH && port.all(Char::isDigit)) + val isValidHost = + if (host.startsWith(IPV6_START)) { + host.removeSurrounding(IPV6_START.toString(), IPV6_END.toString()).isNotEmpty() + } else { + host.trimEnd(HOST_LABEL_SEPARATOR).split(HOST_LABEL_SEPARATOR).none { it.isEmpty() } + } + + return isValidHost && isValidPort + } + + fun toComparable(server: String): String { + val scheme = server.substringBefore(SCHEME_SEPARATOR, "").lowercase(Locale.ROOT) + val withoutScheme = removeScheme(server) + val authority = withoutScheme.substringBefore(PATH_SEPARATOR) + val path = + withoutScheme + .removePrefix(authority) + .trimEnd(PATH_SEPARATOR) + .removeSuffix(INDEX_PHP_PATH) + .trimEnd(PATH_SEPARATOR) + + val host = authority.toHost() + val isIpv6 = host.startsWith(IPV6_START) + val port = authority.removePrefix(host).removePrefix(PORT_SEPARATOR.toString()) + val defaultPort = if (scheme == HTTP_SCHEME) HTTP_PORT else HTTPS_PORT + val effectivePort = port.takeUnless { it.isEmpty() || it == defaultPort } ?: HTTPS_PORT + val comparablePort = if (effectivePort == HTTPS_PORT) "" else "$PORT_SEPARATOR$effectivePort" + val comparableHost = if (isIpv6) host.lowercase(Locale.ROOT) else host.toComparableHostName() + + return comparableHost + comparablePort + path + } + + private fun String.toHost(): String = if (contains(IPV6_END)) { + substringBefore(IPV6_END) + IPV6_END + } else { + substringBefore(PORT_SEPARATOR) + } + + private fun Char.isForbiddenInServer(): Boolean = isWhitespace() || this == QUERY_START || this == FRAGMENT_START + + private fun String.toComparableHostName(): String { + val host = lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR) + return runCatching { IDN.toASCII(host, IDN.ALLOW_UNASSIGNED) }.getOrDefault(host) + } +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt index 57f79c2b..1d84867a 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt @@ -18,7 +18,7 @@ import android.view.View import androidx.core.net.toUri import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.core.R -import java.util.regex.Pattern +import com.nextcloud.android.common.core.utils.AccountNameUtils /** * EcosystemManager: handles sending and receiving account info across apps @@ -37,16 +37,8 @@ class EcosystemManager(private val activity: Activity) { private const val PLAY_STORE_LINK = "https://play.google.com/store/apps/details?id=" private const val PLAY_STORE_MARKET_LINK = "market://details?id=" private const val EXTRA_KEY_ACCOUNT = "KEY_ACCOUNT" - - const val ACCOUNT_NAME_PATTERN_REGEX = - "^[A-Za-z0-9._%+-]+(?:@(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})?" + - "@(?:(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,}|(?:\\d{1,3}\\.){3}\\d{1,3})" + - "(?::\\d{1,5})?" + - "(?:/[^\\s?#]*)?$" } - private val accountNamePattern = Pattern.compile(ACCOUNT_NAME_PATTERN_REGEX) - /** * Opens an ecosystem app with the given account information. * @@ -74,7 +66,7 @@ class EcosystemManager(private val activity: Activity) { } // validate account name - if (!accountNamePattern.matcher(accountName).matches()) { + if (!AccountNameUtils.isValid(accountName)) { showSnackbar(R.string.ecosystem_invalid_account) return } @@ -184,7 +176,7 @@ class EcosystemManager(private val activity: Activity) { return } - if (!accountNamePattern.matcher(account).matches()) { + if (!AccountNameUtils.isValid(account)) { val message = activity.getString(R.string.ecosystem_received_account_invalid) callback.onAccountError(message) return diff --git a/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt deleted file mode 100644 index c861d2d9..00000000 --- a/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.core.utils - -import com.nextcloud.android.common.core.utils.ecosystem.EcosystemManager -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.regex.Pattern - -/** - * Unit tests for validating account name format using the same - * regular expression as {@link EcosystemManager}. - * - * These tests verify that: - * - Valid account names (e.g. "abc@example.cloud.com") are accepted - * - Invalid or malformed account names are rejected - * - Edge cases such as empty or blank strings do not match - * - * The account name is expected to follow an email-like format and is - * used when passing account information between ecosystem apps. - */ -class AccountNamePatternTest { - private val pattern = Pattern.compile(EcosystemManager.ACCOUNT_NAME_PATTERN_REGEX) - - @Test - fun `valid account names should match`() { - assertTrue(pattern.matcher("abc@example.cloud.com").matches()) - assertTrue(pattern.matcher("user.name+test@sub.domain.org").matches()) - assertTrue(pattern.matcher("user_123@test.co").matches()) - } - - @Test - fun `invalid account names should not match`() { - assertFalse(pattern.matcher("abc").matches()) - assertFalse(pattern.matcher("abc@").matches()) - assertFalse(pattern.matcher("abc@example").matches()) - assertFalse(pattern.matcher("abc@example.").matches()) - assertFalse(pattern.matcher("abc@.com").matches()) - assertFalse(pattern.matcher("@example.com").matches()) - assertFalse(pattern.matcher("abc@example.c").matches()) - assertFalse(pattern.matcher("abc example@test.com").matches()) - } - - @Test - fun `empty or blank account names should not match`() { - assertFalse(pattern.matcher("").matches()) - assertFalse(pattern.matcher(" ").matches()) - } - - @Test - fun `special account name formats`() { - assertTrue(pattern.matcher("abc@cloud.com@example.inst2.cloud.com").matches()) - assertTrue(pattern.matcher("admin@10.0.2.2:55002").matches()) - assertTrue(pattern.matcher("admin@10.0.2.2").matches()) - assertTrue(pattern.matcher("test@example.tld@cloud.example.tld:443/subfolder/deep").matches()) - } -} diff --git a/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNameUtilsTest.kt b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNameUtilsTest.kt new file mode 100644 index 00000000..8c4cbdbf --- /dev/null +++ b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNameUtilsTest.kt @@ -0,0 +1,196 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AccountNameUtilsTest { + private val accountNames = + mapOf( + "user@cloud.example.com" to ("user" to "cloud.example.com"), + "John Doe@cloud.example.com" to ("John Doe" to "cloud.example.com"), + "user'name@cloud.example.com" to ("user'name" to "cloud.example.com"), + "john.doe@example.com@cloud.example.com" to ("john.doe@example.com" to "cloud.example.com"), + "a@b@c@cloud.example.com" to ("a@b@c" to "cloud.example.com"), + "CORP\\user@cloud.example.com" to ("CORP\\user" to "cloud.example.com"), + "ünïcode@cloud.example.com" to ("ünïcode" to "cloud.example.com"), + "user@cloud.example.com:8443/nextcloud" to ("user" to "cloud.example.com:8443/nextcloud"), + "user@[2001:db8::1]:8443" to ("user" to "[2001:db8::1]:8443"), + "user@localhost" to ("user" to "localhost") + ) + + @Test + fun `user and server parts are split at the last separator`() { + accountNames.forEach { (accountName, parts) -> + assertEquals(accountName, parts.first, AccountNameUtils.getUserPart(accountName)) + assertEquals(accountName, parts.second, AccountNameUtils.getServerPart(accountName)) + } + } + + @Test + fun `build is reversed by user and server parts`() { + accountNames.values.forEach { (user, server) -> + val accountName = AccountNameUtils.build(user, server) + assertEquals(user, AccountNameUtils.getUserPart(accountName)) + assertEquals(server, AccountNameUtils.getServerPart(accountName)) + } + } + + @Test + fun `account name without separator has no server part`() { + assertEquals("user", AccountNameUtils.getUserPart("user")) + assertEquals("", AccountNameUtils.getServerPart("user")) + assertEquals("", AccountNameUtils.getServerPart("user@")) + } + + @Test + fun `scheme is removed from server urls`() { + assertEquals( + "cloud.example.com/nextcloud", + AccountNameUtils.removeScheme("https://cloud.example.com/nextcloud") + ) + assertEquals("cloud.example.com:8080", AccountNameUtils.removeScheme("http://cloud.example.com:8080")) + assertEquals("cloud.example.com", AccountNameUtils.removeScheme("cloud.example.com")) + } + + @Test + fun `equivalent servers are the same server`() { + listOf( + "https://server.net" to "https://server.net", + "https://server.net/" to "https://server.net", + "https://server.net/index.php" to "https://server.net", + "https://server.net/nextcloud/" to "https://server.net/nextcloud/index.php", + "https://Server.NET" to "https://server.net", + "https://server.net:443" to "https://server.net", + "http://server.net:80" to "http://server.net", + "http://server.net" to "https://server.net", + "http://server.net:443" to "https://server.net", + "https://server.net:8443" to "https://server.net:8443", + "https://my_host.local" to "https://My_Host.local", + "https://[2001:DB8::1]:8443/nc" to "https://[2001:db8::1]:8443/nc", + "https://bücher.example" to "https://xn--bcher-kva.example", + "https://cloud.example.com." to "https://cloud.example.com", + "http://10.0.2.2:8080" to "http://10.0.2.2:8080", + "https://localhost" to "https://localhost", + "cloud.example.com:8443/nextcloud" to "https://cloud.example.com:8443/nextcloud/", + "Cloud.Example.com" to "https://cloud.example.com" + ).forEach { (first, second) -> + assertTrue("$first vs $second", AccountNameUtils.isSameServer(first, second)) + } + } + + @Test + fun `different servers are not the same server`() { + listOf( + "https://server.net" to "https://other.net", + "https://server.net" to "https://sub.server.net", + "https://server.net:8443" to "https://server.net", + "https://server.net:80" to "https://server.net", + "https://server.net/nextcloud" to "https://server.net", + "https://server.net/Nextcloud" to "https://server.net/nextcloud", + "https://[2001:db8::1]:8443" to "https://[2001:db8::1:8443]", + "https://[2001:db8::1]" to "https://[2001:db8::2]", + "cloud.example.com" to "cloud.example.com:8443" + ).forEach { (first, second) -> + assertFalse("$first vs $second", AccountNameUtils.isSameServer(first, second)) + } + } + + @Test + fun `same user id on the same server is the same account`() { + assertTrue(AccountNameUtils.isSameAccount("https://cloud.example.com", "user", "cloud.example.com/", "User")) + assertTrue( + AccountNameUtils.isSameAccount( + "https://cloud.example.com/nextcloud", + "john.doe@example.com", + "https://Cloud.Example.com/nextcloud/", + "john.doe@example.com" + ) + ) + } + + @Test + fun `different user id or server is not the same account`() { + assertFalse( + AccountNameUtils.isSameAccount("https://cloud.example.com", "user", "https://cloud.example.com", "other") + ) + assertFalse( + AccountNameUtils.isSameAccount("https://cloud.example.com", "user", "https://other.example.com", "user") + ) + assertFalse( + AccountNameUtils.isSameAccount("https://cloud.example.com", "user", "https://cloud.example.com/nc", "user") + ) + } + + @Test + fun `account names in user at server format are valid`() { + listOf( + "abc@example.cloud.com", + "user.name+test@sub.domain.org", + "user_123@test.co", + "abc@example", + "abc@example.", + "abc@example.c", + "abc example@test.com", + "o'brien@cloud.example.com", + "john doe@cloud.example.com", + "-user@cloud.example.com", + ".user@cloud.example.com", + "_@cloud.example.com", + "a@cloud.example.com", + "user+tag!#$%&*@cloud.example.com", + "domain\\user@cloud.example.com", + "日本語@cloud.example.com", + "user@localhost", + "user@localhost:8080", + "user@my_host.local", + "user@[::1]", + "user@[::1]:8443/nextcloud", + "user@bücher.example", + "user@xn--bcher-kva.example", + "user@cloud.example.com.:443", + "user@cloud.example.com/", + "user@cloud.example.com/nextcloud", + "user@cloud.example.com/my%20cloud", + "user@cloud.example.com/@nextcloud", + "abc@cloud.com@example.inst2.cloud.com", + "admin@10.0.2.2:55002", + "admin@10.0.2.2", + "test@example.tld@cloud.example.tld:443/subfolder/deep" + ).forEach { + assertTrue(it, AccountNameUtils.isValid(it)) + } + } + + @Test + fun `malformed account names are not valid`() { + listOf( + "", + " ", + "abc", + "abc@", + "abc@.com", + "@example.com", + " user@cloud.example.com", + "user@cloud.example.com ", + "user\n@cloud.example.com", + "user@cloud..example.com", + "user@cloud.example.com:123456", + "user@cloud.example.com:", + "user@cloud.example.com?x=1", + "user@cloud.example.com#fragment", + "user@cloud.example.com/my cloud", + "user@[]" + ).forEach { + assertFalse(it, AccountNameUtils.isValid(it)) + } + } +}