Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Nextcloud Android Common Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Nextcloud Android Common Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down

This file was deleted.

Loading
Loading