A modern camera library for Compose Multiplatform supporting Android, iOS, and Desktop with a unified API.
- 📱 Cross-Platform: Android, iOS, and JVM Desktop
- 📸 Compose-First: Native Compose Multiplatform API
- 🎯 Flexible Configuration: Aspect ratios, zoom, flash control
- 🔌 Plugin System: Modular QR/barcode scanning, OCR, image saving
- ⚡ Optimized Capture: Direct file saving with
takePictureToFile() - 📷 Advanced Control: Camera selection (ultra-wide, telephoto on iOS)
Add dependencies to your build.gradle.kts:
dependencies {
// Core library
implementation("io.github.kashif-mehmood-km:camerak:0.2.0")
// Optional plugins
implementation("io.github.kashif-mehmood-km:image_saver_plugin:0.2.0")
implementation("io.github.kashif-mehmood-km:qr_scanner_plugin:0.2.0")
implementation("io.github.kashif-mehmood-km:ocr_plugin:0.2.0")
}Add to your libs.versions.toml:
[versions]
camerak = "0.2.0"
[libraries]
camerak = { module = "io.github.kashif-mehmood-km:camerak", version.ref = "camerak" }
camerak-image-saver = { module = "io.github.kashif-mehmood-km:image_saver_plugin", version.ref = "camerak" }
camerak-qr-scanner = { module = "io.github.kashif-mehmood-km:qr_scanner_plugin", version.ref = "camerak" }
camerak-ocr = { module = "io.github.kashif-mehmood-km:ocr_plugin", version.ref = "camerak" }Then in your build.gradle.kts:
dependencies {
implementation(libs.camerak)
implementation(libs.camerak.image.saver)
implementation(libs.camerak.qr.scanner)
implementation(libs.camerak.ocr)
}Android - Add to AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />iOS - Add to Info.plist:
<key>NSCameraUsageDescription</key>
<string>Camera access required for taking photos</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Photo library access required for saving images</string>The library now uses reactive state management with StateFlow for a seamless Compose experience:
@Composable
fun CameraScreen() {
val permissions = providePermissions()
val scope = rememberCoroutineScope()
// Create camera state holder - all configuration and plugins handled here
val stateHolder = rememberCameraKState(
permissions = permissions,
cameraConfiguration = {
setCameraLens(CameraLens.BACK)
setFlashMode(FlashMode.OFF)
setAspectRatio(AspectRatio.RATIO_16_9)
},
plugins = listOf(
rememberImageSaverPlugin(config = ImageSaverConfig(isAutoSave = true)),
rememberQRScannerPlugin(),
rememberOcrPlugin()
)
)
// Observe camera state reactively
val cameraState by stateHolder.cameraState.collectAsStateWithLifecycle()
val qrCodes by stateHolder.qrCodeFlow.collectAsStateWithLifecycle(initial = emptyList())
val recognizedText by stateHolder.recognizedTextFlow.collectAsStateWithLifecycle(initial = "")
// Render based on state
when (cameraState) {
is CameraKState.Initializing -> CircularProgressIndicator()
is CameraKState.Ready -> {
val controller = (cameraState as CameraKState.Ready).controller
CameraPreviewComposable(
modifier = Modifier.fillMaxSize(),
controller = controller
)
// UI elements overlay the camera
Button(
onClick = {
scope.launch {
when (val result = controller.takePictureToFile()) {
is ImageCaptureResult.SuccessWithFile -> {
println("Saved: ${result.filePath}")
}
is ImageCaptureResult.Error -> {
println("Error: ${result.exception.message}")
}
}
}
},
modifier = Modifier.align(Alignment.BottomCenter)
) {
Text("Capture Photo")
}
// Display QR codes
if (qrCodes.isNotEmpty()) {
Text("QR: ${qrCodes.last()}", modifier = Modifier.align(Alignment.TopStart))
}
// Display OCR results
if (recognizedText.isNotEmpty()) {
Text("Text: $recognizedText", modifier = Modifier.align(Alignment.TopEnd))
}
}
is CameraKState.Error -> {
Text("Camera Error: ${(cameraState as CameraKState.Error).exception.message}")
}
}
}Creates a reactive camera state holder that manages all camera operations, plugin lifecycle, and state:
@Composable
fun rememberCameraKState(
permissions: PermissionController,
cameraConfiguration: CameraConfiguration.() -> Unit = {},
plugins: List<CameraKPlugin> = emptyList(),
onStateChange: (CameraKState) -> Unit = {}
): CameraKStateHolderReturns: CameraKStateHolder with:
cameraState: StateFlow<CameraKState>- Observable camera lifecycle stateqrCodeFlow: StateFlow<List<String>>- Scanned QR codes (auto-populated by plugin)recognizedTextFlow: StateFlow<String>- OCR results (auto-populated by plugin)eventsFlow: StateFlow<CameraEvent>- Camera events (focus, zoom, etc.)pluginScope: CoroutineScope- For plugin lifecycle management
sealed class CameraKState {
data object Initializing : CameraKState()
data class Ready(val controller: CameraController) : CameraKState()
data class Error(val exception: Exception) : CameraKState()
}State Lifecycle:
Initializing→ Camera starting, permissions requested, hardware initializingReady→ Camera operational, all plugins auto-activated, ready for captureError→ Initialization failed, camera unavailable, permissions denied
| Platform | Min Version | Backend |
|---|---|---|
| Android | API 21+ | CameraX |
| iOS | iOS 13.0+ | AVFoundation |
| Desktop | JDK 11+ | JavaCV |
Configure camera behavior in the rememberCameraKState() call:
val stateHolder = rememberCameraKState(
permissions = permissions,
cameraConfiguration = {
// Camera selection
setCameraLens(CameraLens.BACK) // FRONT or BACK
// Visual settings
setAspectRatio(AspectRatio.RATIO_16_9) // 4:3, 16:9, 9:16, 1:1
setResolution(1920 to 1080) // Optional specific resolution
// Flash control
setFlashMode(FlashMode.AUTO) // ON, OFF, AUTO
// Image output
setImageFormat(ImageFormat.JPEG) // JPEG or PNG
setDirectory(Directory.PICTURES) // DCIM, DOCUMENTS, etc.
// iOS only: Advanced camera device types
setCameraDeviceType(CameraDeviceType.ULTRA_WIDE)
// Options: DEFAULT, ULTRA_WIDE, TELEPHOTO, DUAL_CAMERA, TRIPLE_CAMERA
},
plugins = listOf(
rememberImageSaverPlugin(),
rememberQRScannerPlugin(),
rememberOcrPlugin()
)
)| Option | Type | Default | Description |
|---|---|---|---|
setCameraLens() |
CameraLens |
BACK |
Front or back camera |
setAspectRatio() |
AspectRatio |
RATIO_16_9 |
4:3, 16:9, 9:16, or 1:1 |
setResolution() |
Pair<Int, Int> |
Device default | Specific width × height |
setFlashMode() |
FlashMode |
AUTO |
ON, OFF, or AUTO |
setImageFormat() |
ImageFormat |
JPEG |
JPEG or PNG |
setDirectory() |
Directory |
PICTURES |
DCIM, DOCUMENTS, DOWNLOADS, CACHE |
setCameraDeviceType() |
CameraDeviceType |
DEFAULT |
iOS: DEFAULT, ULTRA_WIDE, TELEPHOTO, etc. |
Access runtime camera control via the CameraController:
// Get controller from Ready state
val cameraState by stateHolder.cameraState.collectAsStateWithLifecycle()
when (cameraState) {
is CameraKState.Ready -> {
val controller = (cameraState as CameraKState.Ready).controller
// Zoom
controller.setZoom(2.5f)
val maxZoom = controller.getMaxZoom()
val currentZoom = controller.getZoom()
// Flash
controller.setFlashMode(FlashMode.ON)
controller.toggleFlashMode() // Cycles: OFF → ON → AUTO
val mode = controller.getFlashMode()
// Torch (continuous light)
controller.toggleTorchMode()
// Camera lens
controller.setCameraLens(CameraLens.FRONT)
controller.toggleCameraLens() // Switches between FRONT/BACK
}
}Standard video aspect ratios for different use cases:
AspectRatio.RATIO_4_3 // Standard (old phones, broadcasts)
AspectRatio.RATIO_16_9 // Widescreen (most common)
AspectRatio.RATIO_9_16 // Vertical stories (Instagram, TikTok)
AspectRatio.RATIO_1_1 // Square (Instagram feed)ImageFormat.JPEG // Lossy compression, smaller files, web-ready
ImageFormat.PNG // Lossless compression, larger files, transparency supportCameraK uses an auto-activating plugin system. Plugins are attached via rememberCameraKState() and automatically activate when the camera reaches the Ready state. This eliminates manual lifecycle management and provides clean reactive patterns.
How Plugins Work:
- Plugin created and attached via
plugins = listOf(...)inrememberCameraKState() - State holder calls
plugin.onAttach(stateHolder)when mounting - Plugin observes
stateHolder.cameraStateand auto-activates whenReady - Plugin processes camera frames or handles capture events
- Plugin cancels all operations on
onDetach()(automatic cleanup)
Automatically saves captured images with customizable naming and storage location.
val imageSaverPlugin = rememberImageSaverPlugin(
config = ImageSaverConfig(
isAutoSave = false, // Manual save vs. auto-save on capture
prefix = "MyApp", // Filename prefix
directory = Directory.PICTURES, // Storage directory
customFolderName = "MyAppPhotos" // Android: custom folder in app directory
)
)Images automatically saved when camera captures:
val imageSaverPlugin = rememberImageSaverPlugin(
config = ImageSaverConfig(isAutoSave = true)
)
// Add to camera state
val stateHolder = rememberCameraKState(
permissions = permissions,
plugins = listOf(imageSaverPlugin)
)
// Capture automatically saves
scope.launch {
when (val result = stateHolder.controller?.takePictureToFile()) {
is ImageCaptureResult.SuccessWithFile -> {
// Already auto-saved by plugin
println("File saved: ${result.filePath}")
}
}
}val imageSaverPlugin = rememberImageSaverPlugin(
config = ImageSaverConfig(isAutoSave = false)
)
// Manual save
scope.launch {
when (val result = stateHolder.controller?.takePicture()) {
is ImageCaptureResult.Success -> {
imageSaverPlugin.saveImage(
byteArray = result.byteArray,
imageName = "Photo_${System.currentTimeMillis()}"
)
}
}
}| Directory | Android Path | iOS Path |
|---|---|---|
PICTURES |
/DCIM/ or Pictures/ |
Photos app |
DOCUMENTS |
Documents/ |
Files > Documents |
DOWNLOADS |
Downloads/ |
Files > Downloads |
Real-time QR code detection from camera frames. Results automatically streamed via qrCodeFlow.
val qrScannerPlugin = rememberQRScannerPlugin()
val stateHolder = rememberCameraKState(
permissions = permissions,
plugins = listOf(qrScannerPlugin)
)
// Observe QR codes
val qrCodes by stateHolder.qrCodeFlow.collectAsStateWithLifecycle(initial = emptyList())
Text("Last QR: ${qrCodes.lastOrNull() ?: "Scanning..."}")// Start scanning (called automatically on camera ready)
qrScannerPlugin.startScanning()
// Pause scanning (useful after detecting a code)
qrScannerPlugin.pauseScanning()
// Resume scanning
qrScannerPlugin.resumeScanning()
// Get scan results
LaunchedEffect(Unit) {
stateHolder.qrCodeFlow
.distinctUntilChanged()
.collectLatest { qrCode ->
println("QR Code: $qrCode")
qrScannerPlugin.pauseScanning()
// Process result...
delay(2000)
qrScannerPlugin.resumeScanning()
}
}Optical Character Recognition - detects and extracts text from camera frames. Results automatically streamed via recognizedTextFlow.
val ocrPlugin = rememberOcrPlugin()
val stateHolder = rememberCameraKState(
permissions = permissions,
plugins = listOf(ocrPlugin)
)
// Observe recognized text
val recognizedText by stateHolder.recognizedTextFlow.collectAsStateWithLifecycle(initial = "")
Text("Detected text: $recognizedText", modifier = Modifier.align(Alignment.TopStart))scope.launch {
when (val result = stateHolder.controller?.takePicture()) {
is ImageCaptureResult.Success -> {
val text = ocrPlugin.recognizeText(result.byteArray)
println("Text: $text")
}
}
}- English, Spanish, French, German, Italian, Portuguese, Chinese, Japanese, Korean
- Multi-language detection automatic
Plugins implement simple lifecycle interface:
interface CameraKPlugin {
fun onAttach(stateHolder: CameraKStateHolder)
fun onDetach()
}@Stable
class CustomTextPlugin : CameraKPlugin {
override fun onAttach(stateHolder: CameraKStateHolder) {
// Option 1: Observe state and auto-activate
stateHolder.pluginScope.launch {
stateHolder.cameraState
.filterIsInstance<CameraKState.Ready>()
.collect { ready ->
startTextDetection(ready.controller)
}
}
}
override fun onDetach() {
// Cleanup: cancel jobs, close resources
}
private suspend fun startTextDetection(controller: CameraController) {
// Your detection logic here
}
}If you have custom plugins using the deprecated getController() approach:
// ❌ OLD (v0.2.0) - Callback based
override fun onAttach(stateHolder: CameraKStateHolder) {
val controller = stateHolder.getController() // Deprecated
startDetection(controller)
}
// ✅ NEW (v0.2.0+) - Reactive state based
override fun onAttach(stateHolder: CameraKStateHolder) {
stateHolder.pluginScope.launch {
stateHolder.cameraState
.filterIsInstance<CameraKState.Ready>()
.collect { ready ->
startDetection(ready.controller)
}
}
}Main entry point for all camera operations. Created via rememberCameraKState():
interface CameraKStateHolder {
// State flows - observe for reactivity
val cameraState: StateFlow<CameraKState> // Initializing/Ready/Error
val qrCodeFlow: StateFlow<List<String>> // Auto-populated by QRScannerPlugin
val recognizedTextFlow: StateFlow<String> // Auto-populated by OcrPlugin
val eventsFlow: StateFlow<CameraEvent> // Camera events (focus, zoom, etc.)
// Plugin management
fun attachPlugin(plugin: CameraKPlugin)
fun detachPlugin(plugin: CameraKPlugin)
val pluginScope: CoroutineScope // For plugin lifecycle operations
// Utilities
suspend fun getReadyCameraController(): CameraController? // Wait until camera ready
}Low-level camera operations returned in CameraKState.Ready:
interface CameraController {
// Capture operations
suspend fun takePictureToFile(): ImageCaptureResult // Recommended - direct file save
@Deprecated("Use takePictureToFile()")
suspend fun takePicture(): ImageCaptureResult // Legacy - returns ByteArray
// Zoom control
fun setZoom(zoom: Float)
fun getZoom(): Float
fun getMaxZoom(): Float
// Flash control
fun setFlashMode(mode: FlashMode)
fun getFlashMode(): FlashMode?
fun toggleFlashMode()
// Torch control
fun toggleTorchMode()
// Camera selection
fun setCameraLens(lens: CameraLens)
fun toggleCameraLens()
// Session management
fun startSession()
fun stopSession()
// Event listeners
fun addImageCaptureListener(listener: (ByteArray) -> Unit)
fun removeImageCaptureListener(listener: (ByteArray) -> Unit)
}Base interface for all plugins:
interface CameraKPlugin {
/**
* Called when plugin attached to camera state holder.
* Use this to observe [CameraKStateHolder.cameraState] and auto-activate.
*/
fun onAttach(stateHolder: CameraKStateHolder)
/**
* Called when plugin detached or component destroyed.
* Cancel all jobs and cleanup resources here.
*/
fun onDetach()
}Result of image capture operations:
sealed class ImageCaptureResult {
data class SuccessWithFile(val filePath: String) : ImageCaptureResult()
data class Success(val byteArray: ByteArray) : ImageCaptureResult() // Deprecated
data class Error(val exception: Exception) : ImageCaptureResult()
}Events emitted during camera operation:
sealed class CameraEvent {
data class FocusChanged(val x: Float, val y: Float) : CameraEvent()
data class ZoomChanged(val level: Float) : CameraEvent()
data class FlashModeChanged(val mode: FlashMode) : CameraEvent()
data class LensChanged(val lens: CameraLens) : CameraEvent()
}
## Migration Guide: v0.2.0 → v0.2.0
### API Migration
The v0.2.0 release introduces a new Compose-first reactive API. The old callback-based API is deprecated but still supported with a one-year deprecation timeline:
- **v0.2.0**: New reactive API available, old API functional with warnings
- **v1.0.0**: Old API marked as error-level deprecation (IDE warnings, future removal)
- **v2.0.0**: Old API removed completely
#### Option 1: Simple Migration (Recommended)
Old callback-based approach:
```kotlin
// ❌ v0.2.0 - Callback based
CameraPreview(
cameraConfiguration = { /* ... */ },
onCameraControllerReady = { controller ->
// Manual controller management
}
)New reactive approach:
// ✅ v0.2.0+ - Reactive StateFlow based
@Composable
fun CameraScreen() {
val stateHolder = rememberCameraKState(
permissions = providePermissions(),
cameraConfiguration = { /* ... */ }
)
val cameraState by stateHolder.cameraState.collectAsStateWithLifecycle()
when (cameraState) {
is CameraKState.Ready -> {
val controller = (cameraState as CameraKState.Ready).controller
// Use controller here
}
}
}If you need to wait for camera readiness:
scope.launch {
val controller = stateHolder.getReadyCameraController()
if (controller != null) {
// Camera is ready - safe to use
when (val result = controller.takePictureToFile()) {
is ImageCaptureResult.SuccessWithFile -> { /* ... */ }
is ImageCaptureResult.Error -> { /* ... */ }
}
}
}If you've created custom plugins using the old API:
class MyPlugin : CameraKPlugin {
override fun onAttach(stateHolder: CameraKStateHolder) {
val controller = stateHolder.getController() // Deprecated
// Directly use controller - race condition risk!
startDetection(controller)
}
}Option A - Stream observation (reactive):
class MyPlugin : CameraKPlugin {
private var job: Job? = null
override fun onAttach(stateHolder: CameraKStateHolder) {
job = stateHolder.pluginScope.launch {
stateHolder.cameraState
.filterIsInstance<CameraKState.Ready>()
.collect { ready ->
startDetection(ready.controller)
}
}
}
override fun onDetach() {
job?.cancel()
}
}Option B - Suspend until ready:
class MyPlugin : CameraKPlugin {
override fun onAttach(stateHolder: CameraKStateHolder) {
stateHolder.pluginScope.launch {
val controller = stateHolder.getReadyCameraController()
if (controller != null) {
startDetection(controller)
}
}
}
override fun onDetach() {
// Cleanup if needed
}
}The old getController() method is deprecated:
// ⚠️ Warning level (v0.2.0)
@Deprecated(
"Use cameraState.filterIsInstance<Ready>() instead. " +
"Will be removed in v2.0.0",
replaceWith = ReplaceWith(
"pluginScope.launch { cameraState.filterIsInstance<CameraKState.Ready>().collect { ... } }"
),
level = DeprecationLevel.WARNING
)
fun getController(): CameraController?
// ⛔ Error level (v1.0.0)
// Same method but with DeprecationLevel.ERROR
// ❌ Removed (v2.0.0)
// Method no longer existsThe takePicture() method is deprecated in favor of takePictureToFile():
// ❌ Deprecated - Manual file handling required
@Deprecated("Use takePictureToFile() instead")
suspend fun takePicture(): ImageCaptureResult
// ✅ Recommended - Direct file save, 2-3x faster
suspend fun takePictureToFile(): ImageCaptureResultPerformance Benefit:
takePicture(): ByteArray in memory → manual file write (slower, ~2-3 seconds)takePictureToFile(): Direct file save (faster, ~0.5-1 second)
| Feature | v0.2.0 | v0.2.0+ | Timeline |
|---|---|---|---|
| Callback-based API | ✅ | v2.0.0 removal | |
| Reactive StateFlow API | ❌ | ✅ Recommended | Current |
getController() |
✅ | v2.0.0 removal | |
takePicture() |
✅ | v2.0.0 removal | |
takePictureToFile() |
❌ | ✅ Recommended | Current |
| Plugin auto-activation | ❌ | ✅ Built-in | Current |
rememberCameraKState() |
❌ | ✅ New | Current |
See PLUGIN_MIGRATION_GUIDE.md for comprehensive custom plugin migration examples.
// ❌ Deprecated (slower, will be removed in v2.0)
when (val result = controller.takePicture()) {
is ImageCaptureResult.Success -> {
val byteArray = result.byteArray
// Manual file save required
}
}
// ✅ Recommended (2-3x faster)
when (val result = controller.takePictureToFile()) {
is ImageCaptureResult.SuccessWithFile -> {
val filePath = result.filePath
// File already saved
}
}// ❌ Deprecated (iOS-only)
val permissions = rememberIOSPermissions()
// ✅ Recommended (cross-platform)
val permissions = providePermissions()Problem: Plugins try to access camera before it's ready.
Solution: Always observe cameraState and wait for Ready:
// ❌ Wrong - immediate access
override fun onAttach(stateHolder: CameraKStateHolder) {
val controller = stateHolder.getController() // Might be null!
startDetection(controller)
}
// ✅ Correct - wait for Ready state
override fun onAttach(stateHolder: CameraKStateHolder) {
stateHolder.pluginScope.launch {
stateHolder.cameraState
.filterIsInstance<CameraKState.Ready>()
.collect { ready ->
startDetection(ready.controller)
}
}
}Problem: Plugins attached but not starting operations.
Solution: Ensure you're using rememberCameraKState():
// ❌ Wrong - old callback API
CameraPreview(
onCameraControllerReady = { controller ->
// Auto-activation not supported
}
)
// ✅ Correct - new reactive API
val stateHolder = rememberCameraKState(
permissions = permissions,
plugins = listOf(myPlugin) // Auto-activates when Ready
)Problem: Plugins continue running after unmount.
Solution: Cancel jobs in onDetach():
class MyPlugin : CameraKPlugin {
private var job: Job? = null
override fun onAttach(stateHolder: CameraKStateHolder) {
job = stateHolder.pluginScope.launch {
// Collector will auto-cancel on DisposableEffect cleanup
stateHolder.cameraState
.filterIsInstance<CameraKState.Ready>()
.collect { ready -> startDetection(ready.controller) }
}
}
override fun onDetach() {
job?.cancel() // Explicit cleanup
}
}Problem: Scanning active but no codes detected.
Solution: Verify plugin is attached and check permissions:
val qrPlugin = rememberQRScannerPlugin()
LaunchedEffect(Unit) {
if (!permissions.hasCameraPermission()) {
permissions.RequestCameraPermission(
onGranted = { /* Try again */ },
onDenied = { /* Handle */ }
)
}
}
val stateHolder = rememberCameraKState(
permissions = permissions,
plugins = listOf(qrPlugin)
)
// Verify codes coming through
val qrCodes by stateHolder.qrCodeFlow.collectAsStateWithLifecycle(initial = emptyList())
Text("Codes detected: ${qrCodes.size}")Problem: Text recognition accuracy low on camera stream.
Solution: Use high-resolution images for recognition:
// ✅ Better accuracy - from captured image
scope.launch {
when (val result = controller.takePictureToFile()) {
is ImageCaptureResult.SuccessWithFile -> {
val file = File(result.filePath)
val byteArray = file.readBytes()
val text = ocrPlugin.recognizeText(byteArray)
}
}
}
// ⚠️ Lower accuracy - real-time stream
// ocrPlugin.recognizedTextFlow auto-updated from streamProblem: "Unresolved reference" for plugin classes.
Solution: Ensure plugins are added to dependencies:
dependencies {
implementation("io.github.kashif-mehmood-km:camerak:0.2.0")
implementation("io.github.kashif-mehmood-km:qr_scanner_plugin:0.2.0")
implementation("io.github.kashif-mehmood-km:ocr_plugin:0.2.0")
implementation("io.github.kashif-mehmood-km:image_saver_plugin:0.2.0")
}Problem: UI frame rate drops when camera active.
Solution: Use collectAsStateWithLifecycle() instead of collectAsState():
// ❌ Causes recomposition on every frame
val qrCodes by stateHolder.qrCodeFlow.collectAsState() // Dangerous!
// ✅ Lifecycle-aware, fewer recompositions
val qrCodes by stateHolder.qrCodeFlow.collectAsStateWithLifecycle()Enable debug logging to diagnose issues:
// In your initialization code
if (BuildConfig.DEBUG) {
stateHolder.eventsFlow.collectLatest { event ->
Log.d("CameraK", "Event: $event")
}
}Contributions welcome! Please:
- Open an issue to discuss changes
- Fork the repository
- Create a feature branch
- Submit a pull request
If you find this library useful:
Apache License 2.0
Copyright 2025 Kashif Mehmood
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.