diff --git a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt index 830822308..d06fe34fd 100644 --- a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt +++ b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt @@ -18,6 +18,7 @@ import com.owncloud.android.lib.common.OwnCloudClientFactory.DEFAULT_CONNECTION_ import com.owncloud.android.lib.common.OwnCloudClientFactory.DEFAULT_DATA_TIMEOUT_LONG import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.interceptor.ClientInterceptor import com.owncloud.android.lib.common.network.AdvancedX509KeyManager import com.owncloud.android.lib.common.network.AdvancedX509TrustManager import com.owncloud.android.lib.common.network.NetworkUtils @@ -43,6 +44,7 @@ class NextcloudClient private constructor( val context: Context ) : NextcloudUriProvider by delegate { var followRedirects = true + private val interceptor = ClientInterceptor() constructor( baseUri: Uri, @@ -126,6 +128,7 @@ class NextcloudClient private constructor( @Throws(IOException::class) fun execute(method: OkHttpMethodBase): Int { + interceptor.interceptOkHttpMethodBaseRequest(method) val httpStatus = method.execute(this) if (httpStatus == HttpStatus.SC_BAD_REQUEST) { val uri = method.uri @@ -137,7 +140,10 @@ class NextcloudClient private constructor( internal fun execute(request: Request): ResponseOrError = try { + interceptor.interceptOkHttp3Request(request) val response = client.newCall(request).execute() + interceptor.interceptOkHttp3Response(response) + if (response.code == HttpStatus.SC_BAD_REQUEST) { val url = request.url Log_OC.e(TAG, "Received http status 400 for $url -> removing client certificate") @@ -150,6 +156,7 @@ class NextcloudClient private constructor( @Throws(IOException::class) fun followRedirection(method: OkHttpMethodBase): RedirectionPath { + interceptor.interceptOkHttpMethodBaseRequest(method) var redirectionsCount = 0 var status = method.getStatusCode() val result = RedirectionPath(status, OwnCloudClient.MAX_REDIRECTIONS_COUNT) @@ -179,6 +186,7 @@ class NextcloudClient private constructor( } status = method.execute(this) + interceptor.interceptOkHttpMethodBaseResponse(method, status) result.addStatus(status) redirectionsCount++ } else { diff --git a/library/src/main/java/com/nextcloud/common/OkHttpMethodBase.kt b/library/src/main/java/com/nextcloud/common/OkHttpMethodBase.kt index aba9c2391..ac8ecb89d 100644 --- a/library/src/main/java/com/nextcloud/common/OkHttpMethodBase.kt +++ b/library/src/main/java/com/nextcloud/common/OkHttpMethodBase.kt @@ -36,7 +36,7 @@ abstract class OkHttpMethodBase( private var response: Response? = null private var queryMap: Map = HashMap() - private val requestHeaders: MutableMap = HashMap() + val requestHeaders: MutableMap = HashMap() private val requestBuilder: Request.Builder = Request.Builder() private var request: Request? = null @@ -152,6 +152,16 @@ abstract class OkHttpMethodBase( return response?.code ?: UNKNOWN_STATUS_CODE } + fun getRequestBodyAsString(): String = + try { + val copy = request?.newBuilder()?.build() + val buffer = okio.Buffer() + copy?.body?.writeTo(buffer) + buffer.readUtf8() + } catch (_: Exception) { + "" + } + abstract fun applyType(temp: Request.Builder) fun isSuccess(): Boolean = getStatusCode() == HttpURLConnection.HTTP_OK diff --git a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java index 3d10638ee..b745326bd 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java +++ b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java @@ -23,6 +23,7 @@ import com.nextcloud.common.DNSCache; import com.nextcloud.common.NextcloudUriDelegate; import com.owncloud.android.lib.common.accounts.AccountUtils; +import com.owncloud.android.lib.common.interceptor.ClientInterceptor; import com.owncloud.android.lib.common.network.AdvancedX509KeyManager; import com.owncloud.android.lib.common.network.RedirectionPath; import com.owncloud.android.lib.common.utils.Log_OC; @@ -66,6 +67,7 @@ public class OwnCloudClient extends HttpClient { private int mInstanceNumber; private AdvancedX509KeyManager keyManager; + private final ClientInterceptor interceptor = new ClientInterceptor(); /** * Constructor @@ -93,7 +95,6 @@ public OwnCloudClient(Uri baseUri, HttpConnectionManager connectionMgr, Context getParams().setParameter(PARAM_SINGLE_COOKIE_HEADER, PARAM_SINGLE_COOKIE_HEADER_VALUE); applyProxySettings(); - clearCredentials(); } @@ -141,6 +142,7 @@ public void clearCredentials() { * @param connectionTimeout Timeout to set for connection establishment */ public int executeMethod(HttpMethodBase method, int readTimeout, int connectionTimeout) throws IOException { + interceptor.interceptHttpMethodBaseRequest(method); int oldSoTimeout = getParams().getSoTimeout(); int oldConnectionTimeout = getHttpConnectionManager().getParams().getConnectionTimeout(); @@ -158,6 +160,9 @@ public int executeMethod(HttpMethodBase method, int readTimeout, int connectionT Log_OC.e(TAG, "Received http status 400 for " + uri + " -> removing client certificate"); keyManager.removeKeys(uri); } + + interceptor.interceptHttpMethodBaseResponse(method, httpStatus); + return httpStatus; } finally { getParams().setSoTimeout(oldSoTimeout); @@ -175,6 +180,7 @@ public int executeMethod(HttpMethodBase method, int readTimeout, int connectionT */ @Override public int executeMethod(HttpMethod method) throws IOException { + interceptor.interceptHttpMethodRequest(method); final String hostname = method.getURI().getHost(); try { @@ -207,6 +213,7 @@ public int executeMethod(HttpMethod method) throws IOException { // logCookiesAtState("after"); // logSetCookiesAtResponse(method.getResponseHeaders()); + interceptor.interceptHttpMethodResponse(method, status); return status; } catch (SocketTimeoutException | ConnectException e) { diff --git a/library/src/main/java/com/owncloud/android/lib/common/interceptor/ClientInterceptor.kt b/library/src/main/java/com/owncloud/android/lib/common/interceptor/ClientInterceptor.kt new file mode 100644 index 000000000..d8035f12f --- /dev/null +++ b/library/src/main/java/com/owncloud/android/lib/common/interceptor/ClientInterceptor.kt @@ -0,0 +1,229 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: MIT + */ + +package com.owncloud.android.lib.common.interceptor + +import com.nextcloud.common.OkHttpMethodBase +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.common.utils.responseFormat.ResponseFormat +import com.owncloud.android.lib.common.utils.responseFormat.ResponseFormatDetector +import okhttp3.Request +import okhttp3.Response +import org.apache.commons.httpclient.HttpMethod +import org.apache.commons.httpclient.HttpMethodBase +import org.json.JSONArray +import org.json.JSONObject +import org.xml.sax.InputSource +import java.io.StringReader +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.Transformer +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import kotlin.collections.component1 +import kotlin.collections.component2 + +@Suppress("TooManyFunctions") +class ClientInterceptor { + companion object { + private const val TAG = "ClientInterceptor" + } + + fun interceptHttpMethodBaseRequest(method: HttpMethodBase) { + Log_OC.d(TAG, "➡️ Method: ${method.name} 🌐 URL: ${method.uri}") + logHeaders(method.requestHeaders.map { it.name to it.value }) + + if (method is org.apache.commons.httpclient.methods.EntityEnclosingMethod) { + val buffer = java.io.ByteArrayOutputStream() + method.requestEntity?.writeRequest(buffer) + val body = buffer.toString(method.requestCharSet ?: Charsets.UTF_8.name()) + logBody(body, method.getRequestHeader("Content-Type")?.value, "Request") + } + Log_OC.d(TAG, "-------------------------") + } + + fun interceptHttpMethodBaseResponse( + method: HttpMethodBase, + statusCode: Int + ) { + Log_OC.d(TAG, "⬅️ Status Code: $statusCode") + logHeaders(method.responseHeaders.map { it.name to it.value }) + logBody(method.responseBodyAsString, method.getResponseHeader("Content-Type")?.value, "Response") + Log_OC.d(TAG, "-------------------------") + } + + fun interceptHttpMethodRequest(method: HttpMethod) { + Log_OC.d(TAG, "➡️ Method: ${method.name} 🌐 URL: ${method.uri}") + logHeaders(method.requestHeaders.map { it.name to it.value }) + + if (method is org.apache.commons.httpclient.methods.EntityEnclosingMethod) { + val buffer = java.io.ByteArrayOutputStream() + method.requestEntity?.writeRequest(buffer) + val body = buffer.toString(method.requestCharSet ?: Charsets.UTF_8.name()) + logBody(body, method.getRequestHeader("Content-Type")?.value, "Request") + } + Log_OC.d(TAG, "-------------------------") + } + + fun interceptHttpMethodResponse( + method: HttpMethod, + statusCode: Int + ) { + Log_OC.d(TAG, "⬅️ Status Code: $statusCode") + logHeaders(method.responseHeaders.map { it.name to it.value }) + logBody(method.responseBodyAsString, method.getResponseHeader("Content-Type")?.value, "Response") + Log_OC.d(TAG, "-------------------------") + } + + fun interceptOkHttp3Request(request: Request) { + Log_OC.d(TAG, "➡️ Method: ${request.method} 🌐 URL: ${request.url}") + request.headers?.toMultimap()?.let { headerMap -> + logHeaders(headerMap.flatMap { (k, vList) -> vList.map { k to it } }) + } + request.body?.let { + val buffer = okio.Buffer() + it.writeTo(buffer) + logBody(buffer.readUtf8(), it.contentType()?.toString(), "Request") + } + Log_OC.d(TAG, "-------------------------") + } + + fun interceptOkHttp3Response(response: Response) { + Log_OC.d(TAG, "⬅️ Status: ${response.code}") + response.headers?.toMultimap()?.let { headerMap -> + logHeaders(headerMap.flatMap { (k, vList) -> vList.map { k to it } }) + } + + val body = + try { + response.peekBody(Long.MAX_VALUE) + } catch (_: Exception) { + null + } + + body?.string()?.let { bodyString -> + logBody(bodyString, response.body?.contentType()?.toString(), "Response") + } + + Log_OC.d(TAG, "-------------------------") + } + + fun interceptOkHttpMethodBaseRequest(method: OkHttpMethodBase) { + Log_OC.d(TAG, "➡️ Method: ${method.javaClass.simpleName} 🌐 URL: ${method.uri}") + logHeaders(method.requestHeaders.map { it.key to it.value }) + logBody(method.getRequestBodyAsString(), method.getRequestHeader("Content-Type"), "Request") + Log_OC.d(TAG, "-------------------------") + } + + fun interceptOkHttpMethodBaseResponse( + method: OkHttpMethodBase, + statusCode: Int + ) { + Log_OC.d(TAG, "⬅️ Status Code: $statusCode") + logHeaders(method.getResponseHeaders().toMultimap().flatMap { (k, vList) -> vList.map { k to it } }) + logBody(method.getResponseBodyAsString(), method.getResponseHeader("Content-Type"), "Response") + Log_OC.d(TAG, "-------------------------") + } + + // region Private Methods + private val xmlDocBuilder = + DocumentBuilderFactory + .newInstance() + .apply { + isNamespaceAware = true + isIgnoringComments = true + isIgnoringElementContentWhitespace = true + }.newDocumentBuilder() + + private val threadLocalTransformer = ThreadLocal() + + private fun getTransformer(): Transformer { + var transformer = threadLocalTransformer.get() + if (transformer == null) { + transformer = + TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.INDENT, "yes") + setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2") + setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no") + setOutputProperty(OutputKeys.ENCODING, "UTF-8") + } + threadLocalTransformer.set(transformer) + } + return transformer + } + + private fun formatXml(xml: String): String = + try { + val characterStream = StringReader(xml) + val inputSource = InputSource(characterStream) + val doc = xmlDocBuilder.parse(inputSource) + val writer = StringWriter() + val domSource = DOMSource(doc) + val streamResult = StreamResult(writer) + getTransformer().transform(domSource, streamResult) + writer.toString() + } catch (_: Exception) { + xml + } + + private fun formatJson( + json: String, + indent: Int = 2 + ): String = + try { + val trimmed = json.trim() + when { + ResponseFormatDetector.isJsonObject(trimmed) -> JSONObject(trimmed).toString(indent) + ResponseFormatDetector.isJsonArray(trimmed) -> JSONArray(trimmed).toString(indent) + else -> json + } + } catch (_: Exception) { + json + } + + private fun formatBody( + body: String, + contentType: String + ): String { + val bodyFormat = ResponseFormatDetector.detectFormat(body) + + return when { + contentType.contains("xml", true) || bodyFormat == ResponseFormat.XML -> formatXml(body) + contentType.contains("json", true) || bodyFormat == ResponseFormat.JSON -> formatJson(body) + else -> body + } + } + + private fun isValidContentType(contentType: String): Boolean = + contentType.contains("application/json") || + contentType.contains("text") || + contentType.contains("xml") || + contentType.isEmpty() + + private fun logHeaders(headers: Iterable>) { + headers.forEach { (name, value) -> Log_OC.d(TAG, "📑 Header: $name: $value") } + } + + @Suppress("TooGenericExceptionCaught") + private fun logBody( + body: String?, + contentType: String?, + label: String + ) { + if (!body.isNullOrBlank() && isValidContentType(contentType ?: "")) { + try { + val formatted = formatBody(body, contentType ?: "") + Log_OC.d(TAG, "📦 $label Body:\n$formatted") + } catch (e: Exception) { + Log_OC.w(TAG, "⚠️ Error reading $label body: $e") + } + } + } + // endregion +} diff --git a/library/src/main/java/com/owncloud/android/lib/common/utils/Log_OC.java b/library/src/main/java/com/owncloud/android/lib/common/utils/Log_OC.java index 705537755..a18649307 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/utils/Log_OC.java +++ b/library/src/main/java/com/owncloud/android/lib/common/utils/Log_OC.java @@ -62,6 +62,10 @@ public interface Adapter { void wtf(String tag, String message); } + public static boolean isEnabled() { + return isEnabled; + } + /** * This is legacy logger implementation extracted to allow * the code to compile and run without hiccup. diff --git a/library/src/main/java/com/owncloud/android/lib/common/utils/responseFormat/ResponseFormatDetector.kt b/library/src/main/java/com/owncloud/android/lib/common/utils/responseFormat/ResponseFormatDetector.kt index ed77deb9a..382959c07 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/utils/responseFormat/ResponseFormatDetector.kt +++ b/library/src/main/java/com/owncloud/android/lib/common/utils/responseFormat/ResponseFormatDetector.kt @@ -7,37 +7,24 @@ package com.owncloud.android.lib.common.utils.responseFormat -import com.owncloud.android.lib.common.utils.Log_OC import org.json.JSONArray -import org.json.JSONException import org.json.JSONObject import java.io.ByteArrayInputStream import javax.xml.parsers.DocumentBuilderFactory object ResponseFormatDetector { - private const val TAG = "ResponseFormatDetector" - fun detectFormat(input: String): ResponseFormat = when { - isJSON(input) -> ResponseFormat.JSON isXML(input) -> ResponseFormat.XML + isJSON(input) -> ResponseFormat.JSON else -> ResponseFormat.UNKNOWN } - private fun isJSON(input: String): Boolean = - try { - JSONObject(input) - true - } catch (e: JSONException) { - try { - Log_OC.i(TAG, "Info it's not JSONObject: $e") - JSONArray(input) - true - } catch (e: JSONException) { - Log_OC.e(TAG, "Exception it's not JSONArray: $e") - false - } - } + private fun isJSON(input: String): Boolean = isJsonObject(input) || isJsonArray(input) + + fun isJsonObject(input: String): Boolean = runCatching { JSONObject(input) }.isSuccess + + fun isJsonArray(input: String): Boolean = runCatching { JSONArray(input) }.isSuccess @Suppress("TooGenericExceptionCaught") private fun isXML(input: String): Boolean = @@ -47,8 +34,7 @@ object ResponseFormatDetector { val stream = ByteArrayInputStream(input.toByteArray()) builder.parse(stream) true - } catch (e: Exception) { - Log_OC.e(TAG, "Exception isXML: $e") + } catch (_: Exception) { false } }