Skip to content
Closed
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
30 changes: 30 additions & 0 deletions composeshield/api/composeshield.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ final enum class io.github.composeshield/CaptureState : kotlin/Enum<io.github.co
final fun values(): kotlin/Array<io.github.composeshield/CaptureState> // io.github.composeshield/CaptureState.values|values#static(){}[0]
}

final enum class io.github.composeshield/ComposeShieldLogLevel : kotlin/Enum<io.github.composeshield/ComposeShieldLogLevel> { // 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 <get-entries>(): kotlin.enums/EnumEntries<io.github.composeshield/ComposeShieldLogLevel> // io.github.composeshield/ComposeShieldLogLevel.entries.<get-entries>|<get-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> // io.github.composeshield/ComposeShieldLogLevel.values|values#static(){}[0]
}

final enum class io.github.composeshield/TaskSwitcherProtection : kotlin/Enum<io.github.composeshield/TaskSwitcherProtection> { // 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]
Expand All @@ -48,6 +61,10 @@ final enum class io.github.composeshield/TaskSwitcherProtection : kotlin/Enum<io
final fun values(): kotlin/Array<io.github.composeshield/TaskSwitcherProtection> // 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]
Expand Down Expand Up @@ -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 <get-screenshotEvents>(): kotlinx.coroutines.flow/Flow<kotlin/Unit> // io.github.composeshield/ComposeShield.screenshotEvents.<get-screenshotEvents>|<get-screenshotEvents>(){}[0]

final var logger // io.github.composeshield/ComposeShield.logger|{}logger[0]
final fun <get-logger>(): io.github.composeshield/ComposeShieldLogger // io.github.composeshield/ComposeShield.logger.<get-logger>|<get-logger>(){}[0]
final fun <set-logger>(io.github.composeshield/ComposeShieldLogger) // io.github.composeshield/ComposeShield.logger.<set-logger>|<set-logger>(io.github.composeshield.ComposeShieldLogger){}[0]
final var taskSwitcherProtection // io.github.composeshield/ComposeShield.taskSwitcherProtection|{}taskSwitcherProtection[0]
final fun <get-taskSwitcherProtection>(): io.github.composeshield/TaskSwitcherProtection // io.github.composeshield/ComposeShield.taskSwitcherProtection.<get-taskSwitcherProtection>|<get-taskSwitcherProtection>(){}[0]
final fun <set-taskSwitcherProtection>(io.github.composeshield/TaskSwitcherProtection) // io.github.composeshield/ComposeShield.taskSwitcherProtection.<set-taskSwitcherProtection>|<set-taskSwitcherProtection>(io.github.composeshield.TaskSwitcherProtection){}[0]
Expand All @@ -105,11 +125,21 @@ final object io.github.composeshield/ComposeShield { // io.github.composeshield/
final fun unprotect(kotlin.collections/Set<io.github.composeshield/Capability> = ...) // io.github.composeshield/ComposeShield.unprotect|unprotect(kotlin.collections.Set<io.github.composeshield.Capability>){}[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 <get-None>(): io.github.composeshield/ComposeShieldLogger // io.github.composeshield/ComposeShieldLoggers.None.<get-None>|<get-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<io.github.composeshield/Capability>?, kotlin/Function1<io.github.composeshield/Capability, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.composeshield/SecureContent|SecureContent(kotlin.collections.Set<io.github.composeshield.Capability>?;kotlin.Function1<io.github.composeshield.Capability,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;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]
Original file line number Diff line number Diff line change
@@ -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)
}
}
},
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -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<ComposeShieldLogLevel>()
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<String>()
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)
}
}
Loading