diff --git a/composeshield/api/composeshield.klib.api b/composeshield/api/composeshield.klib.api index c317b86..2c5a2f1 100644 --- a/composeshield/api/composeshield.klib.api +++ b/composeshield/api/composeshield.klib.api @@ -36,6 +36,19 @@ final enum class io.github.composeshield/CaptureState : kotlin/Enum // io.github.composeshield/CaptureState.values|values#static(){}[0] } +final enum class io.github.composeshield/ComposeShieldLogLevel : kotlin/Enum { // io.github.composeshield/ComposeShieldLogLevel|null[0] + enum entry Debug // io.github.composeshield/ComposeShieldLogLevel.Debug|null[0] + enum entry Error // io.github.composeshield/ComposeShieldLogLevel.Error|null[0] + enum entry Info // io.github.composeshield/ComposeShieldLogLevel.Info|null[0] + enum entry Warn // io.github.composeshield/ComposeShieldLogLevel.Warn|null[0] + + final val entries // io.github.composeshield/ComposeShieldLogLevel.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // io.github.composeshield/ComposeShieldLogLevel.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): io.github.composeshield/ComposeShieldLogLevel // io.github.composeshield/ComposeShieldLogLevel.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // io.github.composeshield/ComposeShieldLogLevel.values|values#static(){}[0] +} + final enum class io.github.composeshield/TaskSwitcherProtection : kotlin/Enum { // io.github.composeshield/TaskSwitcherProtection|null[0] enum entry Always // io.github.composeshield/TaskSwitcherProtection.Always|null[0] enum entry Automatic // io.github.composeshield/TaskSwitcherProtection.Automatic|null[0] @@ -48,6 +61,10 @@ final enum class io.github.composeshield/TaskSwitcherProtection : kotlin/Enum // io.github.composeshield/TaskSwitcherProtection.values|values#static(){}[0] } +abstract interface io.github.composeshield/ComposeShieldLogger { // io.github.composeshield/ComposeShieldLogger|null[0] + abstract fun log(io.github.composeshield/ComposeShieldLogLevel, kotlin/String, kotlin/String, kotlin/Throwable? = ...) // io.github.composeshield/ComposeShieldLogger.log|log(io.github.composeshield.ComposeShieldLogLevel;kotlin.String;kotlin.String;kotlin.Throwable?){}[0] +} + abstract interface io.github.composeshield/ProtectionHandle : kotlin/AutoCloseable { // io.github.composeshield/ProtectionHandle|null[0] abstract fun unprotect() // io.github.composeshield/ProtectionHandle.unprotect|unprotect(){}[0] open fun close() // io.github.composeshield/ProtectionHandle.close|close(){}[0] @@ -95,6 +112,9 @@ final object io.github.composeshield/ComposeShield { // io.github.composeshield/ final val screenshotEvents // io.github.composeshield/ComposeShield.screenshotEvents|{}screenshotEvents[0] final fun (): kotlinx.coroutines.flow/Flow // io.github.composeshield/ComposeShield.screenshotEvents.|(){}[0] + final var logger // io.github.composeshield/ComposeShield.logger|{}logger[0] + final fun (): io.github.composeshield/ComposeShieldLogger // io.github.composeshield/ComposeShield.logger.|(){}[0] + final fun (io.github.composeshield/ComposeShieldLogger) // io.github.composeshield/ComposeShield.logger.|(io.github.composeshield.ComposeShieldLogger){}[0] final var taskSwitcherProtection // io.github.composeshield/ComposeShield.taskSwitcherProtection|{}taskSwitcherProtection[0] final fun (): io.github.composeshield/TaskSwitcherProtection // io.github.composeshield/ComposeShield.taskSwitcherProtection.|(){}[0] final fun (io.github.composeshield/TaskSwitcherProtection) // io.github.composeshield/ComposeShield.taskSwitcherProtection.|(io.github.composeshield.TaskSwitcherProtection){}[0] @@ -105,11 +125,21 @@ final object io.github.composeshield/ComposeShield { // io.github.composeshield/ final fun unprotect(kotlin.collections/Set = ...) // io.github.composeshield/ComposeShield.unprotect|unprotect(kotlin.collections.Set){}[0] } +final object io.github.composeshield/ComposeShieldLoggers { // io.github.composeshield/ComposeShieldLoggers|null[0] + final val None // io.github.composeshield/ComposeShieldLoggers.None|{}None[0] + final fun (): io.github.composeshield/ComposeShieldLogger // io.github.composeshield/ComposeShieldLoggers.None.|(){}[0] + + final fun filtering(io.github.composeshield/ComposeShieldLogLevel, io.github.composeshield/ComposeShieldLogger): io.github.composeshield/ComposeShieldLogger // io.github.composeshield/ComposeShieldLoggers.filtering|filtering(io.github.composeshield.ComposeShieldLogLevel;io.github.composeshield.ComposeShieldLogger){}[0] +} + final val io.github.composeshield/io_github_composeshield_ComposeShield$stableprop // io.github.composeshield/io_github_composeshield_ComposeShield$stableprop|#static{}io_github_composeshield_ComposeShield$stableprop[0] +final val io.github.composeshield/io_github_composeshield_ComposeShieldLoggers$stableprop // io.github.composeshield/io_github_composeshield_ComposeShieldLoggers$stableprop|#static{}io_github_composeshield_ComposeShieldLoggers$stableprop[0] final val io.github.composeshield/io_github_composeshield_SupportLevel_Supported$stableprop // io.github.composeshield/io_github_composeshield_SupportLevel_Supported$stableprop|#static{}io_github_composeshield_SupportLevel_Supported$stableprop[0] final val io.github.composeshield/io_github_composeshield_SupportLevel_Unsupported$stableprop // io.github.composeshield/io_github_composeshield_SupportLevel_Unsupported$stableprop|#static{}io_github_composeshield_SupportLevel_Unsupported$stableprop[0] +final fun (io.github.composeshield/ComposeShieldLoggers).io.github.composeshield/osLog(io.github.composeshield/ComposeShieldLogLevel = ...): io.github.composeshield/ComposeShieldLogger // io.github.composeshield/osLog|osLog@io.github.composeshield.ComposeShieldLoggers(io.github.composeshield.ComposeShieldLogLevel){}[0] final fun io.github.composeshield/SecureContent(kotlin.collections/Set?, kotlin/Function1?, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.composeshield/SecureContent|SecureContent(kotlin.collections.Set?;kotlin.Function1?;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun io.github.composeshield/io_github_composeshield_ComposeShield$stableprop_getter(): kotlin/Int // io.github.composeshield/io_github_composeshield_ComposeShield$stableprop_getter|io_github_composeshield_ComposeShield$stableprop_getter(){}[0] +final fun io.github.composeshield/io_github_composeshield_ComposeShieldLoggers$stableprop_getter(): kotlin/Int // io.github.composeshield/io_github_composeshield_ComposeShieldLoggers$stableprop_getter|io_github_composeshield_ComposeShieldLoggers$stableprop_getter(){}[0] final fun io.github.composeshield/io_github_composeshield_SupportLevel_Supported$stableprop_getter(): kotlin/Int // io.github.composeshield/io_github_composeshield_SupportLevel_Supported$stableprop_getter|io_github_composeshield_SupportLevel_Supported$stableprop_getter(){}[0] final fun io.github.composeshield/io_github_composeshield_SupportLevel_Unsupported$stableprop_getter(): kotlin/Int // io.github.composeshield/io_github_composeshield_SupportLevel_Unsupported$stableprop_getter|io_github_composeshield_SupportLevel_Unsupported$stableprop_getter(){}[0] diff --git a/composeshield/src/androidMain/kotlin/io/github/composeshield/ComposeShieldLoggers.android.kt b/composeshield/src/androidMain/kotlin/io/github/composeshield/ComposeShieldLoggers.android.kt new file mode 100644 index 0000000..f23831d --- /dev/null +++ b/composeshield/src/androidMain/kotlin/io/github/composeshield/ComposeShieldLoggers.android.kt @@ -0,0 +1,38 @@ +package io.github.composeshield + +import android.util.Log + +/** + * A [ComposeShieldLogger] backed by [Log], filtered from [minimumLevel] upward. + * + * ``` + * ComposeShield.logger = ComposeShieldLoggers.androidLogcat(ComposeShieldLogLevel.Warn) + * ``` + */ +public fun ComposeShieldLoggers.androidLogcat( + minimumLevel: ComposeShieldLogLevel = ComposeShieldLogLevel.Warn, +): ComposeShieldLogger = + filtering( + minimumLevel, + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) { + val priority = + when (level) { + ComposeShieldLogLevel.Debug -> Log.DEBUG + ComposeShieldLogLevel.Info -> Log.INFO + ComposeShieldLogLevel.Warn -> Log.WARN + ComposeShieldLogLevel.Error -> Log.ERROR + } + if (throwable != null) { + Log.println(priority, tag, "$message\n${Log.getStackTraceString(throwable)}") + } else { + Log.println(priority, tag, message) + } + } + }, + ) diff --git a/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShield.kt b/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShield.kt index cab1742..9e80a36 100644 --- a/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShield.kt +++ b/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShield.kt @@ -1,6 +1,7 @@ package io.github.composeshield import io.github.composeshield.internal.ProtectionRequest +import io.github.composeshield.internal.ShieldLog import io.github.composeshield.internal.resolveCurrentWindowKey import io.github.composeshield.internal.shieldCore import kotlinx.coroutines.flow.Flow @@ -23,6 +24,22 @@ import kotlinx.coroutines.flow.StateFlow * the main thread internally. */ public object ComposeShield { + /** + * Where library diagnostics are delivered. + * + * Defaults to [ComposeShieldLoggers.None] so production stays quiet. Assign a sink such as + * [androidLogcat][ComposeShieldLoggers.androidLogcat] or [osLog][ComposeShieldLoggers.osLog] + * during application startup when you want filtered Logcat or unified logging output. + * + * Security-relevant mechanism failures are always reported through [protectionFailures] + * regardless of this setting. + */ + public var logger: ComposeShieldLogger + get() = ShieldLog.logger + set(value) { + ShieldLog.logger = value + } + /** * Requests protection until the returned handle is unprotected or closed. * diff --git a/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShieldLogLevel.kt b/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShieldLogLevel.kt new file mode 100644 index 0000000..9aac8ab --- /dev/null +++ b/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShieldLogLevel.kt @@ -0,0 +1,13 @@ +package io.github.composeshield + +/** + * Severity of a [ComposeShieldLogger] message. + * + * Ordered from least to most severe so [ComposeShieldLoggers.filtering] can compare thresholds. + */ +public enum class ComposeShieldLogLevel { + Debug, + Info, + Warn, + Error, +} diff --git a/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShieldLogger.kt b/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShieldLogger.kt new file mode 100644 index 0000000..d1a2b7f --- /dev/null +++ b/composeshield/src/commonMain/kotlin/io/github/composeshield/ComposeShieldLogger.kt @@ -0,0 +1,63 @@ +package io.github.composeshield + +/** + * Receives diagnostic output from ComposeShield. + * + * Install one early in process startup — for example in `Application.onCreate` — so warnings and + * errors emitted during library initialization reach your sink. The default is [ComposeShieldLoggers.None], + * which keeps production silent; security-relevant failures are still delivered through + * [ComposeShield.protectionFailures] and [SecureContent]'s `onProtectionFailure`. + */ +public interface ComposeShieldLogger { + /** + * @param level severity of the message. + * @param tag a short, filterable identifier (typically `"ComposeShield"` or a sub-component). + * @param message human-readable detail. Must not contain protected screen content. + * @param throwable optional cause, when the message describes a failure. + */ + public fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable? = null, + ) +} + +/** Built-in [ComposeShieldLogger] implementations and helpers. */ +public object ComposeShieldLoggers { + /** Discards all messages. The default until [ComposeShield.logger] is assigned. */ + public val None: ComposeShieldLogger = + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) = Unit + } + + /** + * Forwards only messages at or above [minimumLevel] to [delegate]. + * + * Use this to keep debug noise out of production while still wiring a single sink: + * ``` + * ComposeShield.logger = ComposeShieldLoggers.filtering(ComposeShieldLogLevel.Warn, mySink) + * ``` + */ + public fun filtering( + minimumLevel: ComposeShieldLogLevel, + delegate: ComposeShieldLogger, + ): ComposeShieldLogger = + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) { + if (level.ordinal >= minimumLevel.ordinal) { + delegate.log(level, tag, message, throwable) + } + } + } +} diff --git a/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ProtectionRegistry.kt b/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ProtectionRegistry.kt index 8c6c0b2..c5dabb8 100644 --- a/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ProtectionRegistry.kt +++ b/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ProtectionRegistry.kt @@ -270,7 +270,7 @@ internal class ProtectionRegistry( mutate { it.copy(failedMechanisms = it.failedMechanisms + prevention) } prevention.forEach { capability -> - println("[ComposeShield] WARNING: Protection mechanism failed for capability: $capability") + ShieldLog.warn(message = "Protection mechanism failed for capability: $capability") runCatching { onProtectionFailure(capability) } } } diff --git a/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldCore.kt b/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldCore.kt index b983e9c..d887885 100644 --- a/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldCore.kt +++ b/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldCore.kt @@ -41,7 +41,12 @@ internal class ShieldCore( SupervisorJob() + mainDispatcher() + CoroutineName("ComposeShield") + - CoroutineExceptionHandler { _, _ -> }, + CoroutineExceptionHandler { _, throwable -> + ShieldLog.error( + message = "Unhandled exception in shield coroutine scope", + throwable = throwable, + ) + }, ) private val _protectionFailures = diff --git a/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldLog.kt b/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldLog.kt new file mode 100644 index 0000000..8c6e8d8 --- /dev/null +++ b/composeshield/src/commonMain/kotlin/io/github/composeshield/internal/ShieldLog.kt @@ -0,0 +1,58 @@ +package io.github.composeshield.internal + +import io.github.composeshield.ComposeShieldLogLevel +import io.github.composeshield.ComposeShieldLogger +import io.github.composeshield.ComposeShieldLoggers +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Process-wide logger holder and internal call sites. + * + * Reads are lock-free so hot paths can log without synchronizing; assignment is atomic so a + * logger installed during startup is visible to every thread immediately. + */ +@OptIn(ExperimentalAtomicApi::class) +internal object ShieldLog { + private const val DEFAULT_TAG = "ComposeShield" + + private val holder = AtomicReference(ComposeShieldLoggers.None) + + var logger: ComposeShieldLogger + get() = holder.load() + set(value) { + holder.store(value) + } + + fun debug( + tag: String = DEFAULT_TAG, + message: String, + ) { + emit(ComposeShieldLogLevel.Debug, tag, message) + } + + fun warn( + tag: String = DEFAULT_TAG, + message: String, + throwable: Throwable? = null, + ) { + emit(ComposeShieldLogLevel.Warn, tag, message, throwable) + } + + fun error( + tag: String = DEFAULT_TAG, + message: String, + throwable: Throwable? = null, + ) { + emit(ComposeShieldLogLevel.Error, tag, message, throwable) + } + + private fun emit( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable? = null, + ) { + runCatching { holder.load().log(level, tag, message, throwable) } + } +} diff --git a/composeshield/src/commonTest/kotlin/io/github/composeshield/ComposeShieldLoggerTest.kt b/composeshield/src/commonTest/kotlin/io/github/composeshield/ComposeShieldLoggerTest.kt new file mode 100644 index 0000000..9b826d7 --- /dev/null +++ b/composeshield/src/commonTest/kotlin/io/github/composeshield/ComposeShieldLoggerTest.kt @@ -0,0 +1,99 @@ +package io.github.composeshield + +import io.github.composeshield.internal.FakePlatformProtection +import io.github.composeshield.internal.ProtectionOutcome +import io.github.composeshield.internal.ProtectionRegistry +import io.github.composeshield.internal.ShieldLog +import io.github.composeshield.internal.WindowKey +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ComposeShieldLoggerTest { + @AfterTest + fun resetLogger() { + ComposeShield.logger = ComposeShieldLoggers.None + } + + @Test + fun `None logger discards messages without throwing`() { + ComposeShield.logger = ComposeShieldLoggers.None + ShieldLog.warn(message = "quiet") + } + + @Test + fun `filtering drops messages below minimum level`() { + val entries = mutableListOf() + ComposeShield.logger = + ComposeShieldLoggers.filtering( + ComposeShieldLogLevel.Warn, + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) { + entries += level + } + }, + ) + + ShieldLog.debug(message = "debug") + ShieldLog.warn(message = "warn") + + assertEquals(listOf(ComposeShieldLogLevel.Warn), entries) + } + + @Test + fun `protection failure is logged when a logger is installed`() { + val entries = mutableListOf() + ComposeShield.logger = + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) { + if (level == ComposeShieldLogLevel.Warn) { + entries += "$tag:$message" + } + } + } + + val registry = + ProtectionRegistry( + FakePlatformProtection().apply { nextOutcome = ProtectionOutcome.Failed }, + ) + registry.acquire(WindowKey("log-test"), setOf(Capability.ScreenshotPrevention)) + + assertEquals(1, entries.size) + assertTrue(entries.single().contains("ScreenshotPrevention")) + } + + @Test + fun `a throwing logger does not crash the library`() { + ComposeShield.logger = + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) { + error("sink bug") + } + } + + val registry = + ProtectionRegistry( + FakePlatformProtection().apply { nextOutcome = ProtectionOutcome.Failed }, + ) + val request = registry.acquire(WindowKey("throwing-sink"), setOf(Capability.ScreenshotPrevention)) + + assertTrue(Capability.ScreenshotPrevention in registry.current.failedMechanisms) + registry.release(request) + } +} diff --git a/composeshield/src/iosMain/kotlin/io/github/composeshield/ComposeShieldLoggers.ios.kt b/composeshield/src/iosMain/kotlin/io/github/composeshield/ComposeShieldLoggers.ios.kt new file mode 100644 index 0000000..be4d16e --- /dev/null +++ b/composeshield/src/iosMain/kotlin/io/github/composeshield/ComposeShieldLoggers.ios.kt @@ -0,0 +1,33 @@ +package io.github.composeshield + +import platform.Foundation.NSLog + +/** + * A [ComposeShieldLogger] backed by [NSLog], filtered from [minimumLevel] upward. + * + * ``` + * ComposeShield.logger = ComposeShieldLoggers.osLog(ComposeShieldLogLevel.Warn) + * ``` + */ +public fun ComposeShieldLoggers.osLog( + minimumLevel: ComposeShieldLogLevel = ComposeShieldLogLevel.Warn, +): ComposeShieldLogger = + filtering( + minimumLevel, + object : ComposeShieldLogger { + override fun log( + level: ComposeShieldLogLevel, + tag: String, + message: String, + throwable: Throwable?, + ) { + val formatted = + if (throwable != null) { + "[$level] $tag: $message\n$throwable" + } else { + "[$level] $tag: $message" + } + NSLog("%@", formatted) + } + }, + ) diff --git a/composeshield/src/iosMain/kotlin/io/github/composeshield/internal/SecureContainer.ios.kt b/composeshield/src/iosMain/kotlin/io/github/composeshield/internal/SecureContainer.ios.kt index 7075358..8dd4c37 100644 --- a/composeshield/src/iosMain/kotlin/io/github/composeshield/internal/SecureContainer.ios.kt +++ b/composeshield/src/iosMain/kotlin/io/github/composeshield/internal/SecureContainer.ios.kt @@ -101,9 +101,11 @@ internal class SecureContainer private constructor( .filterIsInstance() .firstOrNull { it.isCanvasLike() } ?: run { - println( - "[ComposeShield] WARNING: Failed to locate secure CanvasView in UITextField subviews. " + - "SecureContainer protection is unavailable on this iOS runtime.", + ShieldLog.warn( + tag = "SecureContainer", + message = + "Failed to locate secure CanvasView in UITextField subviews; " + + "SecureContainer protection is unavailable on this iOS runtime.", ) return null }