diff --git a/afterpay/build.gradle.kts b/afterpay/build.gradle.kts index 67180521..13abacbf 100644 --- a/afterpay/build.gradle.kts +++ b/afterpay/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(libs.androidxCoreKtx) implementation(libs.androidxAppcompat) + implementation(libs.timber) testImplementation(libs.junit) coreLibraryDesugaring(libs.androidToolsDesugarJdk) diff --git a/afterpay/consumer-rules.pro b/afterpay/consumer-rules.pro index 4641d8ca..1731c2ce 100644 --- a/afterpay/consumer-rules.pro +++ b/afterpay/consumer-rules.pro @@ -18,3 +18,27 @@ -keepclasseswithmembers class com.afterpay.android.** { kotlinx.serialization.KSerializer serializer(...); } + +# Strip all Timber logging calls from release builds +# Note: Keep these method signatures in sync with Timber APIs used by the SDK +-assumenosideeffects class timber.log.Timber { + public *** d(...); + public *** i(...); + public *** w(...); + public *** e(...); + public *** v(...); + public *** wtf(...); +} + +# Strip all AfterpayLog logging calls from release builds +# Note: Keep sanitization helpers as they are pure utility functions +-assumenosideeffects class com.afterpay.android.internal.AfterpayLog { + public *** d(...); + public *** i(...); + public *** w(...); + public *** e(...); + public *** apiRequest(...); + public *** apiSuccess(...); + public *** apiError(...); + public *** checkoutEvent(...); +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt index 464f94f4..6688e109 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt @@ -24,6 +24,7 @@ import com.afterpay.android.cashapp.CashAppSignOrderResult.Failure import com.afterpay.android.cashapp.CashAppSignOrderResult.Success import com.afterpay.android.cashapp.CashAppValidationResponse import com.afterpay.android.internal.AfterpayDrawable +import com.afterpay.android.internal.AfterpayLog import com.afterpay.android.internal.AfterpayString import com.afterpay.android.internal.ApiV3 import com.afterpay.android.internal.Brand @@ -242,7 +243,16 @@ object Afterpay { locale = locale.clone() as Locale, environment = environment, consumerLocale = consumerLocale, - ).also { validateConfiguration(it) } + ).also { config -> + AfterpayLog.d { "Setting Afterpay configuration: environment=${config.environment.name}, locale=${config.locale}, currency=${config.currency}, maxAmount=${config.maximumAmount}" } + try { + validateConfiguration(config) + AfterpayLog.d("Configuration validated successfully") + } catch (e: IllegalArgumentException) { + AfterpayLog.e(e, "Configuration validation failed") + throw e + } + } } private fun validateConfiguration(configuration: Configuration) { diff --git a/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppApi.kt b/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppApi.kt index b33906ac..17ce65f2 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppApi.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppApi.kt @@ -16,6 +16,7 @@ package com.afterpay.android.cashapp import com.afterpay.android.BuildConfig +import com.afterpay.android.internal.AfterpayLog import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -44,12 +45,15 @@ internal object AfterpayCashAppApi { connection.inputStream.bufferedReader().use { reader -> val data = reader.readText() val result = json.decodeFromString(data) + AfterpayLog.d { "Cash App API request successful (HTTP ${connection.responseCode})" } Result.success(result) } } else { + AfterpayLog.w { "Cash App API returned unexpected response code: ${connection.responseCode}" } throw InvalidObjectException("Unexpected response code: ${connection.responseCode}.") } } catch (exception: Exception) { + AfterpayLog.e(exception, "Cash App API request failed") Result.failure(exception) } } diff --git a/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppCheckout.kt b/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppCheckout.kt index 9e4b2be8..05f0e114 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppCheckout.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppCheckout.kt @@ -16,6 +16,7 @@ package com.afterpay.android.cashapp import com.afterpay.android.Afterpay +import com.afterpay.android.internal.AfterpayLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -34,6 +35,7 @@ sealed class CashAppValidationResponse { object AfterpayCashAppCheckout { suspend fun performSignPaymentRequest(token: String): CashAppSignOrderResult { + AfterpayLog.d { "Starting Cash App payment signing with token: ${AfterpayLog.sanitizeToken(token)}" } runCatching { signPayment(token) .let { result: Result -> @@ -48,14 +50,17 @@ object AfterpayCashAppCheckout { jwt = response.jwtToken, ) + AfterpayLog.d("Cash App payment signing successful") return CashAppSignOrderResult.Success(cashApp) } - .onFailure { - return CashAppSignOrderResult.Failure(it) + .onFailure { error -> + AfterpayLog.e(error, "JWT decode failed") + return CashAppSignOrderResult.Failure(error) } } - .onFailure { - return CashAppSignOrderResult.Failure(it) + .onFailure { error -> + AfterpayLog.e(error, "Payment signing failed") + return CashAppSignOrderResult.Failure(error) } } } @@ -65,6 +70,7 @@ object AfterpayCashAppCheckout { // TODO stop using this, no need for suspend *and* callback suspend fun performSignPaymentRequest(token: String, complete: (CashAppSignOrderResult) -> Unit) { + AfterpayLog.d { "Starting Cash App payment signing (callback) with token: ${AfterpayLog.sanitizeToken(token)}" } runCatching { signPayment(token) .onSuccess { response -> @@ -78,14 +84,17 @@ object AfterpayCashAppCheckout { jwt = response.jwtToken, ) + AfterpayLog.d("Cash App payment signing successful (callback)") complete(CashAppSignOrderResult.Success(cashApp)) } - .onFailure { - complete(CashAppSignOrderResult.Failure(it)) + .onFailure { error -> + AfterpayLog.e(error, "JWT decode failed (callback)") + complete(CashAppSignOrderResult.Failure(error)) } } - .onFailure { - complete(CashAppSignOrderResult.Failure(it)) + .onFailure { error -> + AfterpayLog.e(error, "Payment signing failed (callback)") + complete(CashAppSignOrderResult.Failure(error)) } } } @@ -134,12 +143,19 @@ object AfterpayCashAppCheckout { response .onSuccess { when (it.status) { - "SUCCESS" -> complete(CashAppValidationResponse.Success(it)) - else -> complete(CashAppValidationResponse.Failure(Exception("status is ${it.status}"))) + "SUCCESS" -> { + AfterpayLog.d("Cash App validation successful") + complete(CashAppValidationResponse.Success(it)) + } + else -> { + AfterpayLog.w { "Cash App validation failed with status: ${it.status}" } + complete(CashAppValidationResponse.Failure(Exception("status is ${it.status}"))) + } } } - .onFailure { - complete(CashAppValidationResponse.Failure(Exception(it.message))) + .onFailure { error -> + AfterpayLog.e(error, "Cash App validation failed") + complete(CashAppValidationResponse.Failure(Exception(error.message))) } Unit diff --git a/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppJwt.kt b/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppJwt.kt index ff2f995c..7f8bf2f9 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppJwt.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/cashapp/AfterpayCashAppJwt.kt @@ -16,6 +16,7 @@ package com.afterpay.android.cashapp import android.util.Base64 +import com.afterpay.android.internal.AfterpayLog import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -49,12 +50,17 @@ data class AfterpayCashAppJwt( ) { companion object { fun decode(jwt: String): Result { + AfterpayLog.d { "Starting JWT decode for Cash App: ${AfterpayLog.sanitizeToken(jwt)}" } return runCatching { val split = jwt.split(".").toTypedArray() val jwtBody = getJson(split[1]) val json = Json { ignoreUnknownKeys = true } - json.decodeFromString(jwtBody) + val result: AfterpayCashAppJwt = json.decodeFromString(jwtBody) + AfterpayLog.d("JWT decode successful") + result + }.onFailure { error -> + AfterpayLog.e(error, "JWT decode failed") } } diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayLog.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayLog.kt new file mode 100644 index 00000000..4f928f89 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayLog.kt @@ -0,0 +1,357 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afterpay.android.internal + +import com.afterpay.android.BuildConfig +import timber.log.Timber + +/** + * Internal logging facade for the Afterpay SDK. + * + * This facade ensures that: + * 1. Logging only occurs in DEBUG builds (checked via BuildConfig.DEBUG) + * 2. Timber is lazily initialized with a DebugTree only when needed + * 3. Works correctly whether or not consuming apps use Timber + * 4. Gets completely stripped from release builds via ProGuard rules + * + * ## Timber Initialization + * + * If your app already uses Timber, plant your trees **before** calling any Afterpay SDK methods. + * The SDK will detect existing trees and use them. If no trees exist, the SDK will plant a + * default DebugTree for DEBUG builds only. + * + * ## Thread Safety + * + * All logging methods are thread-safe and can be called from any thread. Initialization uses + * double-checked locking with volatile fields to ensure proper memory visibility. + * + * ## Release Builds + * + * All logging calls are automatically removed in release builds by ProGuard rules defined in + * consumer-rules.pro. This ensures zero runtime overhead in production. + */ +internal object AfterpayLog { + + @Volatile private var initialized = false + + @Volatile private var loggingDisabled = false + + /** + * Lazily initializes Timber if in DEBUG mode and no trees are planted yet. + * Thread-safe with double-checked locking. + */ + private fun ensureInitialized() { + if (!BuildConfig.DEBUG || loggingDisabled || initialized) return + + synchronized(this) { + if (!initialized && !loggingDisabled) { + try { + if (Timber.treeCount == 0) { + Timber.plant(Timber.DebugTree()) + } + initialized = true + } catch (e: LinkageError) { + // android.util.Log not available (unit tests without mocking) + loggingDisabled = true + } catch (e: ExceptionInInitializerError) { + // Timber initialization failed + loggingDisabled = true + } + } + } + } + + // ============================================================================ + // String-based logging (eager evaluation) + // ============================================================================ + + /** + * Log a debug message. + */ + fun d(message: String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.d(message) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log a debug message with arguments. + */ + fun d(message: String, vararg args: Any?) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.d(message, *args) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an info message. + */ + fun i(message: String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.i(message) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an info message with arguments. + */ + fun i(message: String, vararg args: Any?) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.i(message, *args) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log a warning message. + */ + fun w(message: String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.w(message) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log a warning message with arguments. + */ + fun w(message: String, vararg args: Any?) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.w(message, *args) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log a warning with a throwable. + */ + fun w(throwable: Throwable, message: String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.w(throwable, message) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an error message. + */ + fun e(message: String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.e(message) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an error message with arguments. + */ + fun e(message: String, vararg args: Any?) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.e(message, *args) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an error with a throwable. + */ + fun e(throwable: Throwable, message: String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.e(throwable, message) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + // ============================================================================ + // Lambda-based logging (lazy evaluation for performance) + // ============================================================================ + + /** + * Log a debug message using lazy evaluation. + * The lambda is only executed if logging is enabled, avoiding string allocation overhead. + * + * Example: `AfterpayLog.d { "API request: ${method.name} ${url}" }` + */ + inline fun d(messageProvider: () -> String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.d(messageProvider()) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an info message using lazy evaluation. + */ + inline fun i(messageProvider: () -> String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.i(messageProvider()) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log a warning message using lazy evaluation. + */ + inline fun w(messageProvider: () -> String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.w(messageProvider()) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an error message using lazy evaluation. + */ + inline fun e(messageProvider: () -> String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.e(messageProvider()) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + /** + * Log an error with throwable using lazy evaluation. + */ + inline fun e(throwable: Throwable, messageProvider: () -> String) { + if (!BuildConfig.DEBUG || loggingDisabled) return + try { + ensureInitialized() + if (!loggingDisabled) Timber.e(throwable, messageProvider()) + } catch (e: RuntimeException) { + loggingDisabled = true + } + } + + // ============================================================================ + // Structured logging helpers + // ============================================================================ + + /** + * Log an API request with structured data. + */ + fun apiRequest(method: String, url: String) { + d { "API request: $method ${sanitizeUrl(url)}" } + } + + /** + * Log a successful API response with structured data. + */ + fun apiSuccess(method: String, statusCode: Int) { + d { "API success: $method (HTTP $statusCode)" } + } + + /** + * Log an API error with structured data. + */ + fun apiError(message: String, statusCode: Int, errorCode: String) { + e { "API error: $message (HTTP $statusCode, code: $errorCode)" } + } + + /** + * Log a checkout event with structured data. + */ + fun checkoutEvent(version: String, event: String, details: String = "") { + i { + if (details.isEmpty()) { + "Checkout $version: $event" + } else { + "Checkout $version: $event - $details" + } + } + } + + // ============================================================================ + // Sanitization utilities + // ============================================================================ + + /** + * Sanitizes a token for safe logging by showing only the first 8 and last 4 characters. + * + * Examples: + * - Long token: "tok_abcdefgh...wxyz" (first 8 + last 4) + * - Short token: "****" (completely masked) + * + * @param token The token to sanitize + * @return Sanitized token safe for logging + */ + fun sanitizeToken(token: String?): String { + if (token == null) return "null" + return when { + token.length <= 12 -> "****" + else -> "${token.take(8)}...${token.takeLast(4)}" + } + } + + /** + * Sanitizes a URL for safe logging by removing query parameters that might contain tokens. + * + * Examples: + * - "https://api.afterpay.com/checkout?token=abc" -> "https://api.afterpay.com/checkout" + * - "https://api.afterpay.com/checkout" -> "https://api.afterpay.com/checkout" + * + * @param url The URL to sanitize + * @return Sanitized URL safe for logging + */ + fun sanitizeUrl(url: String?): String { + if (url == null) return "null" + return url.substringBefore('?') + } +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt index 353c842b..78b427b2 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt @@ -30,6 +30,7 @@ internal object ApiV3 { internal inline fun request(url: URL, method: HttpVerb, body: B): Result { val connection = url.openConnection() as HttpsURLConnection + AfterpayLog.apiRequest(method.name, url.toString()) return try { configure(connection, method) val payload = (body as? String) ?: json.encodeToString(body) @@ -42,14 +43,17 @@ internal object ApiV3 { val data = connection.inputStream.bufferedReader().readText() connection.inputStream.close() val result = json.decodeFromString(data) + AfterpayLog.apiSuccess(method.name, connection.responseCode) Result.success(result) } catch (exception: Exception) { try { val data = connection.errorStream.bufferedReader().readText() connection.errorStream.close() val result = json.decodeFromString(data) + AfterpayLog.apiError(result.message, result.httpStatusCode, result.errorCode) Result.failure(InvalidObjectException(result.message)) } catch (_: Exception) { + AfterpayLog.e(exception, "API request failed") Result.failure(exception) } } finally { @@ -59,6 +63,7 @@ internal object ApiV3 { internal inline fun requestUnit(url: URL, method: HttpVerb, body: B): Result { val connection = url.openConnection() as HttpsURLConnection + AfterpayLog.apiRequest(method.name, url.toString()) return try { configure(connection, method) val payload = (body as? String) ?: json.encodeToString(body) @@ -68,6 +73,7 @@ internal object ApiV3 { outputStreamWriter.flush() if (connection.errorStream == null && connection.responseCode < 400) { + AfterpayLog.apiSuccess(method.name, connection.responseCode) Result.success(Unit) } else { throw InvalidObjectException("Unexpected response code: ${connection.responseCode}") @@ -77,8 +83,10 @@ internal object ApiV3 { val data = connection.errorStream.bufferedReader().readText() connection.errorStream.close() val result = json.decodeFromString(data) + AfterpayLog.apiError(result.message, result.httpStatusCode, result.errorCode) Result.failure(InvalidObjectException(result.message)) } catch (_: Exception) { + AfterpayLog.e(exception, "API requestUnit failed") Result.failure(exception) } } finally { @@ -88,20 +96,24 @@ internal object ApiV3 { internal inline fun get(url: URL): Result { val connection = url.openConnection() as HttpsURLConnection + AfterpayLog.apiRequest("GET", url.toString()) return try { configure(connection, HttpVerb.GET) val data = connection.inputStream.bufferedReader().readText() connection.inputStream.close() val result = json.decodeFromString(data) + AfterpayLog.apiSuccess("GET", connection.responseCode) Result.success(result) } catch (exception: Exception) { try { val data = connection.errorStream.bufferedReader().readText() connection.errorStream.close() val result = json.decodeFromString(data) + AfterpayLog.apiError(result.message, result.httpStatusCode, result.errorCode) Result.failure(InvalidObjectException(result.message)) } catch (_: Exception) { + AfterpayLog.e(exception, "API get failed") Result.failure(exception) } } finally { diff --git a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV2Activity.kt b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV2Activity.kt index dad54cf0..92cc59a1 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV2Activity.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV2Activity.kt @@ -24,7 +24,6 @@ import android.net.Uri import android.os.Bundle import android.os.Message import android.util.Base64 -import android.util.Log import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebChromeClient @@ -47,6 +46,7 @@ import com.afterpay.android.R import com.afterpay.android.internal.AfterpayCheckoutCompletion import com.afterpay.android.internal.AfterpayCheckoutMessage import com.afterpay.android.internal.AfterpayCheckoutV2 +import com.afterpay.android.internal.AfterpayLog import com.afterpay.android.internal.CheckoutLogMessage import com.afterpay.android.internal.Html import com.afterpay.android.internal.ShippingAddressMessage @@ -140,9 +140,14 @@ internal class AfterpayCheckoutV2Activity : AppCompatActivity() { private fun loadCheckoutToken() { if (!Afterpay.enabled) { + AfterpayLog.checkoutEvent("V2", "cancelled", "language not supported") return finish(LANGUAGE_NOT_SUPPORTED) } - val handler = Afterpay.checkoutV2Handler ?: return finish(NO_CHECKOUT_HANDLER) + val handler = Afterpay.checkoutV2Handler + if (handler == null) { + AfterpayLog.checkoutEvent("V2", "cancelled", "no checkout handler set") + return finish(NO_CHECKOUT_HANDLER) + } val configuration = Afterpay.configuration ?: return finish(CancellationStatus.NO_CONFIGURATION) val options = requireNotNull(intent.getCheckoutV2OptionsExtra()) @@ -318,16 +323,20 @@ private class BootstrapJavascriptInterface( @JavascriptInterface fun postMessage(messageJson: String) { runCatching { json.decodeFromString(messageJson) } - .onFailure { Log.d(javaClass.simpleName, it.toString()) } + .onFailure { AfterpayLog.d("Failed to parse checkout message: ${it.message}") } .getOrNull() ?.let { message -> - val handler = Afterpay.checkoutV2Handler ?: return cancel(NO_CHECKOUT_HANDLER) + val handler = Afterpay.checkoutV2Handler + if (handler == null) { + AfterpayLog.checkoutEvent("V2", "cancelled", "no handler in postMessage") + return cancel(NO_CHECKOUT_HANDLER) + } when (message) { - is CheckoutLogMessage -> Log.d( - javaClass.simpleName, - message.payload.run { "${severity.replaceFirstChar { it.uppercase(Locale.ROOT) }}: $message" }, - ) + is CheckoutLogMessage -> { + val logMessage = message.payload.run { "${severity.replaceFirstChar { it.uppercase(Locale.ROOT) }}: $message" } + AfterpayLog.d { "Checkout log: $logMessage" } + } is ShippingAddressMessage -> handler.shippingAddressDidChange(message.payload) { AfterpayCheckoutMessage @@ -359,7 +368,7 @@ private class BootstrapJavascriptInterface( } } ?: runCatching { json.decodeFromString(messageJson) } - .onFailure { Log.d(javaClass.simpleName, it.toString()) } + .onFailure { AfterpayLog.d("Failed to parse checkout completion: ${it.message}") } .getOrNull() ?.let(complete) } diff --git a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt index 6635864a..37a62d90 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.lifecycleScope import com.afterpay.android.Afterpay import com.afterpay.android.CancellationStatusV3 import com.afterpay.android.R +import com.afterpay.android.internal.AfterpayLog import com.afterpay.android.internal.CheckoutV3ViewModel import com.afterpay.android.internal.Html import com.afterpay.android.internal.getCheckoutV3OptionsExtra @@ -72,12 +73,15 @@ internal class AfterpayCheckoutV3Activity : AppCompatActivity() { } lifecycleScope.launchWhenStarted { + AfterpayLog.checkoutEvent("V3", "started") viewModel.performCheckoutRequest() .onSuccess { checkoutRedirectUrl -> + AfterpayLog.d { "Checkout V3 request successful, loading URL: ${AfterpayLog.sanitizeUrl(checkoutRedirectUrl.toString())}" } webView.loadUrl(checkoutRedirectUrl.toString()) } - .onFailure { - received(CancellationStatusV3.REQUEST_ERROR, it as? Exception) + .onFailure { error -> + AfterpayLog.e(error, "Checkout V3 request failed") + received(CancellationStatusV3.REQUEST_ERROR, error as? Exception) } } } @@ -105,6 +109,7 @@ internal class AfterpayCheckoutV3Activity : AppCompatActivity() { } private fun handleError() { + AfterpayLog.checkoutEvent("V3", "error", "WebView load error") // Clear default system error from the web view. webView.loadUrl("about:blank") diff --git a/afterpay/src/test/kotlin/com/afterpay/android/internal/AfterpayLogTest.kt b/afterpay/src/test/kotlin/com/afterpay/android/internal/AfterpayLogTest.kt new file mode 100644 index 00000000..a7034c1d --- /dev/null +++ b/afterpay/src/test/kotlin/com/afterpay/android/internal/AfterpayLogTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afterpay.android.internal + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AfterpayLogTest { + + @Test + fun `sanitizeToken masks long tokens correctly`() { + val token = "tok_abcdefghijklmnopqrstuvwxyz1234567890" + val sanitized = AfterpayLog.sanitizeToken(token) + assertEquals("tok_abcd...7890", sanitized) + } + + @Test + fun `sanitizeToken masks short tokens completely`() { + val token = "shortToken" + val sanitized = AfterpayLog.sanitizeToken(token) + assertEquals("****", sanitized) + } + + @Test + fun `sanitizeToken handles exactly 12 character tokens`() { + val token = "123456789012" + val sanitized = AfterpayLog.sanitizeToken(token) + assertEquals("****", sanitized) + } + + @Test + fun `sanitizeToken handles exactly 13 character tokens`() { + val token = "1234567890123" + val sanitized = AfterpayLog.sanitizeToken(token) + assertEquals("12345678...0123", sanitized) + } + + @Test + fun `sanitizeToken handles null token`() { + val sanitized = AfterpayLog.sanitizeToken(null) + assertEquals("null", sanitized) + } + + @Test + fun `sanitizeUrl removes query parameters`() { + val url = "https://api.afterpay.com/checkout?token=abc123&session=xyz789" + val sanitized = AfterpayLog.sanitizeUrl(url) + assertEquals("https://api.afterpay.com/checkout", sanitized) + } + + @Test + fun `sanitizeUrl handles URLs without query parameters`() { + val url = "https://api.afterpay.com/checkout" + val sanitized = AfterpayLog.sanitizeUrl(url) + assertEquals("https://api.afterpay.com/checkout", sanitized) + } + + @Test + fun `sanitizeUrl handles null URL`() { + val sanitized = AfterpayLog.sanitizeUrl(null) + assertEquals("null", sanitized) + } + + @Test + fun `sanitizeUrl handles URL with fragment after query`() { + val url = "https://api.afterpay.com/checkout?token=abc#section" + val sanitized = AfterpayLog.sanitizeUrl(url) + assertEquals("https://api.afterpay.com/checkout", sanitized) + } + + @Test + fun `logging methods don't crash when called`() { + // In unit tests, BuildConfig.DEBUG may be true but android.util.Log is not available. + // The logging calls may throw RuntimeException due to unmocked Log. + // This is expected behavior in unit tests - in real usage, Log will be available. + // We're just testing that the methods can be called without compilation errors. + try { + AfterpayLog.d("Debug message") + AfterpayLog.d("Debug with args: %s", "arg1") + AfterpayLog.w("Warning message") + AfterpayLog.w("Warning with args: %s", "arg1") + AfterpayLog.w(Exception("Test exception"), "Warning with exception") + AfterpayLog.e("Error message") + AfterpayLog.e("Error with args: %s", "arg1") + AfterpayLog.e(Exception("Test exception"), "Error with exception") + } catch (e: RuntimeException) { + // Expected in unit tests where android.util.Log is not mocked + // In production, this won't happen as Log is always available + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89f4e4fe..4fe70416 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ mockk = "1.13.5" moshi = "1.14.0" # latest version that is compatible with kotlin 1.7.x retrofit = "2.9.0" # latest version that is compatible with kotlin 1.7.x secretsGradlePlugin = "2.0.1" +timber = "5.0.1" tools_desugar_sdk = "2.1.2" [libraries] @@ -43,6 +44,7 @@ moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } [plugins] android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }