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
4 changes: 4 additions & 0 deletions app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.nextcloud.client.widget.DashboardWidgetService
import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet
import com.nextcloud.ui.composeActivity.ComposeActivity
import com.nextcloud.ui.dialog.account.ChooseAccountDialogFragment
import com.nextcloud.ui.dialog.account.CustomHeadersDialogFragment
import com.nextcloud.ui.dialog.account.onlineStatus.SetOnlineStatusBottomSheet
import com.nextcloud.ui.dialog.account.statusMessage.SetStatusMessageBottomSheet
import com.nextcloud.ui.dialog.chooseStorage.ChooseStorageLocationDialogFragment
Expand Down Expand Up @@ -529,4 +530,7 @@ internal interface ComponentsModule {

@ContributesAndroidInjector
fun albumSharingBottomSheet(): AlbumSharingBottomSheet

@ContributesAndroidInjector
fun customHeadersDialogFragment(): CustomHeadersDialogFragment
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.common.NextcloudClient
import com.nextcloud.utils.headers.AccountCustomHeaders
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
Expand Down Expand Up @@ -137,6 +138,7 @@ class AccountRemovalWork(

// delete cached OwncloudClient
OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(user.toOwnCloudAccount())
AccountCustomHeaders.reconcile(context)

if (userRemoved) {
eventBus.post(AccountRemovedEvent())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.ui.dialog.account

import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.DialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.client.account.User
import com.nextcloud.client.di.Injectable
import com.nextcloud.utils.extensions.getParcelableArgument
import com.nextcloud.utils.headers.AccountCustomHeaders
import com.nextcloud.utils.headers.CustomHeadersParseResult
import com.nextcloud.utils.headers.CustomHeadersParser
import com.owncloud.android.R
import com.owncloud.android.databinding.CustomHeadersDialogBinding
import com.owncloud.android.utils.theme.ViewThemeUtils
import javax.inject.Inject

/**
* Edits the custom headers of an existing account, for example after a token rotation or when the server was moved
* behind a pre-authentication proxy.
*/
class CustomHeadersDialogFragment :
DialogFragment(),
Injectable {

@Inject
lateinit var viewThemeUtils: ViewThemeUtils

private lateinit var binding: CustomHeadersDialogBinding
private lateinit var user: User

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
user = requireNotNull(arguments.getParcelableArgument(ARG_USER, User::class.java))
binding = CustomHeadersDialogBinding.inflate(requireActivity().layoutInflater, null, false)
viewThemeUtils.material.colorTextInputLayout(binding.customHeadersContainer)

if (savedInstanceState == null) {
val stored = AccountCustomHeaders.read(requireContext(), user.toPlatformAccount())
binding.customHeadersInput.setText(CustomHeadersParser.format(stored))
}
binding.customHeadersInput.doAfterTextChanged { validate() }

val builder = MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.custom_headers_edit)
.setMessage(R.string.custom_headers_edit_message)
.setView(binding.root)
.setPositiveButton(R.string.common_save) { _, _ -> save() }
.setNegativeButton(R.string.common_cancel, null)
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.customHeadersContainer.context, builder)
return builder.create()
}

override fun onStart() {
super.onStart()
val dialog = dialog as? AlertDialog ?: return
(dialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton)?.let {
viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it)
}
(dialog.getButton(AlertDialog.BUTTON_NEGATIVE) as? MaterialButton)?.let {
viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(it)
}
validate()
}

private fun validate() {
val error = when (val parsed = parse()) {
is CustomHeadersParseResult.Invalid -> getString(R.string.custom_headers_invalid, parsed.lineNumber)

is CustomHeadersParseResult.Valid -> getString(R.string.custom_headers_https_only)
.takeIf { parsed.headers.isNotEmpty() && !isHttps() }
}
binding.customHeadersContainer.error = error
(dialog as? AlertDialog)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = error == null
}

private fun save() {
val parsed = parse() as? CustomHeadersParseResult.Valid ?: return
if (parsed.headers.isNotEmpty() && !isHttps()) {
return
}
AccountCustomHeaders.replace(requireContext(), user.toPlatformAccount(), parsed.headers)
}

private fun parse(): CustomHeadersParseResult =
CustomHeadersParser.parse(binding.customHeadersInput.text?.toString().orEmpty())

private fun isHttps(): Boolean = user.server.uri.scheme.equals(HTTPS, ignoreCase = true)

companion object {
const val TAG = "CustomHeadersDialogFragment"
private const val ARG_USER = "user"
private const val HTTPS = "https"

@JvmStatic
fun newInstance(user: User): CustomHeadersDialogFragment =
CustomHeadersDialogFragment().apply { arguments = bundleOf(ARG_USER to user) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.headers

import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.core.net.toUri
import com.nextcloud.common.CustomHeaders
import com.owncloud.android.MainApp
import com.owncloud.android.lib.common.accounts.AccountUtils
import com.owncloud.android.lib.common.utils.Log_OC

/**
* Custom headers are stored per account and registered for the origin of the account at app start.
*/
object AccountCustomHeaders {
private const val TAG = "AccountCustomHeaders"
private const val KEY_CUSTOM_HEADERS = "oc_custom_headers"

@JvmStatic
fun registerAll(context: Context) {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(MainApp.getAccountType(context)).forEach { register(accountManager, it) }
}

/**
* Drops the headers of removed accounts and keeps the ones of the remaining accounts.
*/
@JvmStatic
fun reconcile(context: Context) {
CustomHeaders.origins.forEach { CustomHeaders.unregister(it) }
registerAll(context)
}

fun store(context: Context, account: Account, json: String) {
AccountManager.get(context).setUserData(account, KEY_CUSTOM_HEADERS, json)
}

fun read(context: Context, account: Account): Map<String, String> =
CustomHeaders.fromJson(AccountManager.get(context).getUserData(account, KEY_CUSTOM_HEADERS)).orEmpty()

/**
* Empty headers remove the stored ones. Takes effect for every following request.
*/
fun replace(context: Context, account: Account, headers: Map<String, String>) {
val json = headers.takeIf { it.isNotEmpty() }?.let { CustomHeaders.toJson(it) }
AccountManager.get(context).setUserData(account, KEY_CUSTOM_HEADERS, json)
reconcile(context)
}

private fun register(accountManager: AccountManager, account: Account) {
val headers = CustomHeaders.fromJson(accountManager.getUserData(account, KEY_CUSTOM_HEADERS)) ?: return
val baseUri = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL)?.toUri()
if (!CustomHeaders.register(baseUri, headers)) {
Log_OC.e(TAG, "custom headers of an account are invalid and will be ignored")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.headers

import android.content.Context
import com.nextcloud.common.PlainClient
import com.owncloud.android.lib.common.network.NetworkUtils
import com.owncloud.android.lib.common.utils.Log_OC
import okhttp3.OkHttpClient
import java.io.IOException
import java.security.GeneralSecurityException
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLSession

/**
* Library client for requests that the app does not send through a library client itself (web views, Glide).
*/
object CustomHeadersHttpClient {
private const val TAG = "CustomHeadersHttpClient"

@Volatile
private var client: OkHttpClient? = null

@Volatile
private var clientWithoutRedirects: OkHttpClient? = null

/**
* The library client accepts any host name. Web views reject a mismatching certificate unless the user
* accepted it, so this transport does the same before it sends custom headers.
*/
fun get(context: Context): OkHttpClient = client ?: synchronized(this) {
val appContext = context.applicationContext
client ?: PlainClient(appContext).client.newBuilder()
.hostnameVerifier(hostnameVerifier(appContext))
.build()
.also { client = it }
}

/**
* Web views must see redirects themselves: following them here would serve the final body under the original URL.
*/
fun withoutRedirects(context: Context): OkHttpClient = clientWithoutRedirects ?: synchronized(this) {
clientWithoutRedirects ?: get(context).newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
.also { clientWithoutRedirects = it }
}

private fun hostnameVerifier(context: Context) = HostnameVerifier { host, session ->
HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session) || isAcceptedByUser(context, session)
}

private fun isAcceptedByUser(context: Context, session: SSLSession): Boolean = try {
val certificate = session.peerCertificates.firstOrNull()
certificate != null && NetworkUtils.isCertInKnownServersStore(certificate, context)
} catch (e: GeneralSecurityException) {
Log_OC.e(TAG, "accepted certificates not readable: ${e.javaClass.simpleName}")
false
} catch (e: IOException) {
Log_OC.e(TAG, "peer certificate or accepted certificates not readable: ${e.javaClass.simpleName}")
false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.headers

sealed interface CustomHeadersParseResult {
data class Valid(val headers: Map<String, String>) : CustomHeadersParseResult

data class Invalid(val lineNumber: Int) : CustomHeadersParseResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.headers

import com.nextcloud.common.CustomHeaders

object CustomHeadersParser {
private const val SEPARATOR = ':'
private const val MAX_HEADERS = 10
private const val LINE_SEPARATOR = "\n"

fun format(headers: Map<String, String>): String =
headers.entries.joinToString(LINE_SEPARATOR) { (name, value) -> "$name$SEPARATOR $value" }

fun parse(text: String): CustomHeadersParseResult {
val headers = linkedMapOf<String, String>()

text.lines().forEachIndexed { index, line ->
if (line.isBlank()) {
return@forEachIndexed
}

val separatorIndex = line.indexOf(SEPARATOR)
val name = line.substring(0, maxOf(separatorIndex, 0)).trim()
val value = line.substring(separatorIndex + 1).trim()
val duplicate = headers.keys.any { it.equals(name, ignoreCase = true) }

if (separatorIndex < 0 || duplicate || !CustomHeaders.isValid(name, value)) {
return CustomHeadersParseResult.Invalid(index + 1)
}
headers[name] = value
}

if (headers.size > MAX_HEADERS) {
return CustomHeadersParseResult.Invalid(MAX_HEADERS + 1)
}
return CustomHeadersParseResult.Valid(headers)
}
}
Loading