
Simplifies REST API communication and WebSocket management, offering unified network request handling, token-based authentication, localization, and advanced error handling with minimal setup.
A powerful, easy-to-use Kotlin Multiplatform networking library that simplifies REST API communication and WebSocket management with minimal setup.
Add to your build.gradle.kts:
dependencies {
implementation("global.deveng:networking-kmp:latest")
}// 1. Configure the module
val config = DevengNetworkingConfig(
loggingEnabled = true, // Optional - defaults to true
requestTimeoutMillis = 30_000L, // Optional - defaults to 60 seconds
connectTimeoutMillis = 10_000L, // Optional - defaults to 10 seconds
socketTimeoutMillis = 30_000L, // Optional - defaults to 60 seconds
token = "your-auth-token", // Optional - for Bearer auth (omit for cookie-only)
locale = Locale.EN, // Optional - defaults to EN
customHeaders = mapOf( // Optional - for global headers
"X-API-Version" to "1.0",
"X-Client-Platform" to "Android"
),
socketBaseUrl = "wss://ws.example.com" // Optional - only needed for WebSocket
)
// 2. Initialize
DevengNetworkingModule.initDevengNetworkingModule(
restBaseUrl = "https://api.example.com", // Required - your REST API base URL
config = config
)
// 3. Make your first API call
suspend fun getUser(userId: String): User? {
return DevengNetworkingModule.sendRequest<Unit, User?>(
endpoint = "/users/{userId}",
requestMethod = DevengHttpMethod.GET,
pathParameters = mapOf("userId" to userId)
)
}// For Java < 21, use this approach:
suspend fun getUser(userId: String): User {
return DevengNetworkingModule.sendRequest(
endpoint = "/users/{userId}",
requestMethod = DevengHttpMethod.GET,
pathParameters = mapOf("userId" to userId),
requestSerializer = null, // No request body
responseSerializer = User.serializer() // Explicit response serializer
)
}| Feature | Description |
|---|---|
| 🌐 Full REST API Support | GET, POST, PUT, DELETE with automatic serialization |
| 📁 File Upload Support | Simple multipart file uploads with MIME detection |
| 🔄 WebSocket Management | Connection pooling, lifecycle events, automatic reconnection |
| 🎯 Multiplatform | Android, iOS, Desktop (JVM), WebAssembly |
| 🔒 Authentication | Bearer tokens, cookie sessions, CSRF, and refresh-on-401 |
| 🌍 Localization | Localized error messages and headers |
| 🎭 Error Handling | Centralized, customizable exception handling |
| 🛠️ Dynamic Parameters | Path and query parameter injection |
| 📋 Custom Headers | Global and per-request custom headers |
| 🔧 Configurable Logging | Enable/disable logging per environment |
| ⏱️ Timeout Control | Request, connection, and socket timeouts |
// Development
val devConfig = DevengNetworkingConfig(
loggingEnabled = true,
requestTimeoutMillis = 120_000L,
token = "dev-token"
)
// Production
val prodConfig = DevengNetworkingConfig(
loggingEnabled = false,
requestTimeoutMillis = 30_000L,
customHeaders = mapOf("X-Client-Version" to "2.1.0")
)// GET Request
suspend fun getUser(id: String): User? {
return DevengNetworkingModule.sendRequest<Unit, User?>(
endpoint = "/users/{id}",
requestMethod = DevengHttpMethod.GET,
pathParameters = mapOf("id" to id)
)
}
// POST Request
suspend fun createUser(user: CreateUserRequest): User? {
return DevengNetworkingModule.sendRequest<CreateUserRequest, User?>(
endpoint = "/users",
requestBody = user,
requestMethod = DevengHttpMethod.POST
)
}
// PUT Request
suspend fun updateUser(id: String, user: UpdateUserRequest) {
DevengNetworkingModule.sendRequest<UpdateUserRequest, Unit>(
endpoint = "/users/{id}",
requestBody = user,
requestMethod = DevengHttpMethod.PUT,
pathParameters = mapOf("id" to id)
)
}
// DELETE Request
suspend fun deleteUser(id: String) {
DevengNetworkingModule.sendRequest<Unit, Unit>(
endpoint = "/users/{id}",
requestMethod = DevengHttpMethod.DELETE,
pathParameters = mapOf("id" to id)
)
}suspend fun searchUsers(query: String, limit: Int): List<User>? {
return DevengNetworkingModule.sendRequest<Unit, List<User>?>(
endpoint = "/users/search",
requestMethod = DevengHttpMethod.GET,
queryParameters = mapOf(
"query" to query,
"limit" to limit.toString()
)
)
}Use this when you need access to HTTP headers, status codes, or other response metadata:
suspend fun getRawResponse(): HttpResponse {
return DevengNetworkingModule.sendRequestForHttpResponse<Unit>(
endpoint = "/status",
requestMethod = DevengHttpMethod.GET
)
}
// Example usage - accessing response metadata
suspend fun checkApiLimits() {
val response = DevengNetworkingModule.sendRequestForHttpResponse<Unit>(
endpoint = "/api/limits",
requestMethod = DevengHttpMethod.GET
)
// Access headers for rate limiting info
val remainingRequests = response.headers["X-RateLimit-Remaining"]
val resetTime = response.headers["X-RateLimit-Reset"]
// Check exact status code
when (response.status.value) {
200 -> println("API healthy")
429 -> println("Rate limited - retry after $resetTime")
else -> println("Status: ${response.status}")
}
}Common use cases:
X-RateLimit-* headersLink or pagination headersContent-Type, Content-Length, etc.Upload files with multipart form data using the same simplicity as regular API calls with DevengHttpMethod.MULTIPART:
suspend fun uploadProfilePicture(userId: String, imageData: ByteArray) {
DevengNetworkingModule.sendRequest<Unit, Unit>(
endpoint = "/users/$userId/avatar",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = "profile.jpg",
fileContent = imageData
)
}suspend fun uploadDocument(
fileName: String,
fileData: ByteArray,
documentType: String,
isPublic: Boolean
) {
DevengNetworkingModule.sendRequest<Unit, UploadResponse>(
endpoint = "/documents/upload",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = fileName,
fileContent = fileData,
additionalFormFields = mapOf(
"type" to documentType,
"isPublic" to isPublic.toString(),
"uploadedAt" to System.currentTimeMillis().toString()
)
)
}suspend fun uploadAttachment(fileName: String, fileData: ByteArray) {
DevengNetworkingModule.sendRequest<Unit, Unit>(
endpoint = "/attachments",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = fileName,
fileContent = fileData,
fileFieldName = "attachment" // Custom field name instead of default "File"
)
}data class UploadResponse(val fileId: String, val downloadUrl: String)
suspend fun uploadAndGetUrl(fileName: String, fileData: ByteArray): UploadResponse {
return DevengNetworkingModule.sendRequest<Unit, UploadResponse>(
endpoint = "/files/upload",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = fileName,
fileContent = fileData,
additionalFormFields = mapOf("source" to "mobile")
)
}suspend fun uploadFile(fileName: String, fileData: ByteArray): UploadResponse {
return DevengNetworkingModule.sendRequest(
endpoint = "/files/upload",
requestMethod = DevengHttpMethod.MULTIPART,
requestSerializer = null,
responseSerializer = UploadResponse.serializer(),
fileName = fileName,
fileContent = fileData
)
}Multipart File Upload Features:
val connection = DevengNetworkingModule.connectToWebSocket(
endpoint = "/chat",
onConnected = {
sendMessage("Hello Server!")
},
onMessageReceived = { message ->
println("Received: $message")
},
onError = { error ->
println("WebSocket Error: $error")
},
onClose = {
println("Connection closed")
}
)
// Send messages
connection.sendMessage("Hello World!")scope.launch {
connection.connectionState.collect { state ->
when (state) {
is ConnectionState.Connected -> println("✅ Connected")
is ConnectionState.Connecting -> println("🔄 Connecting...")
is ConnectionState.Disconnected -> println("❌ Disconnected")
is ConnectionState.Error -> println("⚠️ Error: ${state.error}")
}
}
}// Configure max connections (default: 5)
WebSocketConnection.setMaxConnections(3)
// Get connection stats
val activeConnections = WebSocketConnection.getActiveConnections()
val connectionCount = WebSocketConnection.getConnectionCount()
// Close specific connection
DevengNetworkingModule.closeWebSocketConnection("/chat")
// Close all connections
DevengNetworkingModule.closeAllWebSocketConnections()try {
val user = DevengNetworkingModule.sendRequest<Unit, User?>(
endpoint = "/users/123",
requestMethod = DevengHttpMethod.GET
)
} catch (e: DevengException) {
println("Error: ${e.message}") // Localized error message
}class CustomExceptionHandler : ExceptionHandler {
override var locale: Locale = Locale.EN
override fun handleHttpException(
errorMessage: String?,
status: HttpStatusCode
): DevengUiError {
// Custom HTTP error logic
return when (status.value) {
404 -> DevengUiError("Resource not found")
500 -> DevengUiError("Server error occurred")
else -> DevengUiError("Request failed: $errorMessage")
}
}
override fun handleNetworkException(cause: Throwable): DevengUiError {
// Custom network error logic
return DevengUiError("Network connection failed")
}
}// Update authentication token at runtime
DevengNetworkingModule.setToken("new-auth-token")When the token is blank (""), DNM omits the Authorization header entirely rather than sending an empty Authorization: Bearer value — useful for cookie-only auth flows.
Install Ktor's HttpCookies plugin with a custom CookiesStorage to persist cookies across requests (and across process restarts, if your storage writes to disk):
val config = DevengNetworkingConfig(
httpClientConfig = {
install(HttpCookies) {
storage = MyPersistentCookiesStorage()
}
},
wasmJsIncludeCredentials = true // Required on wasmJs for cross-origin cookies
)httpClientConfig is a generic Ktor HttpClient extension point — install any plugin you need, not just HttpCookies. wasmJsIncludeCredentials is engine-level and only relevant on the wasmJs target.
Attach a CSRF token header on every POST, PUT, PATCH, DELETE. The provider is called once per mutating request; a null return skips the header for that request:
val config = DevengNetworkingConfig(
csrfTokenProvider = DevengCsrfTokenProvider { myCsrfStore.getToken() },
csrfHeaderName = "X-CSRF-TOKEN" // Optional - defaults to "X-CSRF-TOKEN"
)Two opt-in hooks fire on HTTP 401. sessionRefresher runs first and can recover the session transparently by refreshing and retrying the original request; onUnauthorized fires for every response that is still 401 after that:
val config = DevengNetworkingConfig(
sessionRefresher = DevengSessionRefresher {
try {
val tokens = authService.refreshSession()
devengNetworkingModule.setToken(tokens.accessToken)
true // Retry the failed request
} catch (e: Exception) {
false // Surface UnauthorizedError
}
},
onUnauthorized = { eventBus.emit(SessionExpired) },
refreshTimeoutMillis = 30_000L // Optional - defaults to 30s, <= 0 disables it
)Concurrent 401s are deduplicated by a per-client mutex + generation counter, so only one refresh runs even under heavy parallel load. The refresher can issue HTTP calls through the same DNM client without infinite recursion — an internal coroutine-context marker suppresses re-entrance.
A few consequences worth knowing:
setToken itself. The retry reads the current token and rewrites the Authorization header, so a refresh that does not publish the new token replays the expired one.refreshTimeoutMillis caps a single refresh() call. The single-flight lock is held for its duration, so without a timeout a stalled refresh endpoint blocks every other request that hits a 401.GlobalScope.launch, viewModelScope.launch) drops the re-entrance marker and lets the refresh request trigger another refresh.onUnauthorized fires per failing request, including when no sessionRefresher is configured. With several requests in flight it fires several times, so debounce it on the consumer side.Add to libs.versions.toml:
[versions]
devengNetworkingKmp = "latest"
[libraries]
deveng-networking-kmp = {
module = "global.deveng:networking-kmp",
version.ref = "devengNetworkingKmp"
}Then in build.gradle.kts:
dependencies {
implementation(libs.deveng.networking.kmp)
}The library is built on top of Ktor and provides:
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
We welcome contributions! Please:
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)For major changes, please open an issue first to discuss your proposal.
Created by Deveng Group - Building powerful, developer-friendly tools for the Kotlin ecosystem.
Made with ❤️ by the Deveng Group team
A powerful, easy-to-use Kotlin Multiplatform networking library that simplifies REST API communication and WebSocket management with minimal setup.
Add to your build.gradle.kts:
dependencies {
implementation("global.deveng:networking-kmp:latest")
}// 1. Configure the module
val config = DevengNetworkingConfig(
loggingEnabled = true, // Optional - defaults to true
requestTimeoutMillis = 30_000L, // Optional - defaults to 60 seconds
connectTimeoutMillis = 10_000L, // Optional - defaults to 10 seconds
socketTimeoutMillis = 30_000L, // Optional - defaults to 60 seconds
token = "your-auth-token", // Optional - for Bearer auth (omit for cookie-only)
locale = Locale.EN, // Optional - defaults to EN
customHeaders = mapOf( // Optional - for global headers
"X-API-Version" to "1.0",
"X-Client-Platform" to "Android"
),
socketBaseUrl = "wss://ws.example.com" // Optional - only needed for WebSocket
)
// 2. Initialize
DevengNetworkingModule.initDevengNetworkingModule(
restBaseUrl = "https://api.example.com", // Required - your REST API base URL
config = config
)
// 3. Make your first API call
suspend fun getUser(userId: String): User? {
return DevengNetworkingModule.sendRequest<Unit, User?>(
endpoint = "/users/{userId}",
requestMethod = DevengHttpMethod.GET,
pathParameters = mapOf("userId" to userId)
)
}// For Java < 21, use this approach:
suspend fun getUser(userId: String): User {
return DevengNetworkingModule.sendRequest(
endpoint = "/users/{userId}",
requestMethod = DevengHttpMethod.GET,
pathParameters = mapOf("userId" to userId),
requestSerializer = null, // No request body
responseSerializer = User.serializer() // Explicit response serializer
)
}| Feature | Description |
|---|---|
| 🌐 Full REST API Support | GET, POST, PUT, DELETE with automatic serialization |
| 📁 File Upload Support | Simple multipart file uploads with MIME detection |
| 🔄 WebSocket Management | Connection pooling, lifecycle events, automatic reconnection |
| 🎯 Multiplatform | Android, iOS, Desktop (JVM), WebAssembly |
| 🔒 Authentication | Bearer tokens, cookie sessions, CSRF, and refresh-on-401 |
| 🌍 Localization | Localized error messages and headers |
| 🎭 Error Handling | Centralized, customizable exception handling |
| 🛠️ Dynamic Parameters | Path and query parameter injection |
| 📋 Custom Headers | Global and per-request custom headers |
| 🔧 Configurable Logging | Enable/disable logging per environment |
| ⏱️ Timeout Control | Request, connection, and socket timeouts |
// Development
val devConfig = DevengNetworkingConfig(
loggingEnabled = true,
requestTimeoutMillis = 120_000L,
token = "dev-token"
)
// Production
val prodConfig = DevengNetworkingConfig(
loggingEnabled = false,
requestTimeoutMillis = 30_000L,
customHeaders = mapOf("X-Client-Version" to "2.1.0")
)// GET Request
suspend fun getUser(id: String): User? {
return DevengNetworkingModule.sendRequest<Unit, User?>(
endpoint = "/users/{id}",
requestMethod = DevengHttpMethod.GET,
pathParameters = mapOf("id" to id)
)
}
// POST Request
suspend fun createUser(user: CreateUserRequest): User? {
return DevengNetworkingModule.sendRequest<CreateUserRequest, User?>(
endpoint = "/users",
requestBody = user,
requestMethod = DevengHttpMethod.POST
)
}
// PUT Request
suspend fun updateUser(id: String, user: UpdateUserRequest) {
DevengNetworkingModule.sendRequest<UpdateUserRequest, Unit>(
endpoint = "/users/{id}",
requestBody = user,
requestMethod = DevengHttpMethod.PUT,
pathParameters = mapOf("id" to id)
)
}
// DELETE Request
suspend fun deleteUser(id: String) {
DevengNetworkingModule.sendRequest<Unit, Unit>(
endpoint = "/users/{id}",
requestMethod = DevengHttpMethod.DELETE,
pathParameters = mapOf("id" to id)
)
}suspend fun searchUsers(query: String, limit: Int): List<User>? {
return DevengNetworkingModule.sendRequest<Unit, List<User>?>(
endpoint = "/users/search",
requestMethod = DevengHttpMethod.GET,
queryParameters = mapOf(
"query" to query,
"limit" to limit.toString()
)
)
}Use this when you need access to HTTP headers, status codes, or other response metadata:
suspend fun getRawResponse(): HttpResponse {
return DevengNetworkingModule.sendRequestForHttpResponse<Unit>(
endpoint = "/status",
requestMethod = DevengHttpMethod.GET
)
}
// Example usage - accessing response metadata
suspend fun checkApiLimits() {
val response = DevengNetworkingModule.sendRequestForHttpResponse<Unit>(
endpoint = "/api/limits",
requestMethod = DevengHttpMethod.GET
)
// Access headers for rate limiting info
val remainingRequests = response.headers["X-RateLimit-Remaining"]
val resetTime = response.headers["X-RateLimit-Reset"]
// Check exact status code
when (response.status.value) {
200 -> println("API healthy")
429 -> println("Rate limited - retry after $resetTime")
else -> println("Status: ${response.status}")
}
}Common use cases:
X-RateLimit-* headersLink or pagination headersContent-Type, Content-Length, etc.Upload files with multipart form data using the same simplicity as regular API calls with DevengHttpMethod.MULTIPART:
suspend fun uploadProfilePicture(userId: String, imageData: ByteArray) {
DevengNetworkingModule.sendRequest<Unit, Unit>(
endpoint = "/users/$userId/avatar",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = "profile.jpg",
fileContent = imageData
)
}suspend fun uploadDocument(
fileName: String,
fileData: ByteArray,
documentType: String,
isPublic: Boolean
) {
DevengNetworkingModule.sendRequest<Unit, UploadResponse>(
endpoint = "/documents/upload",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = fileName,
fileContent = fileData,
additionalFormFields = mapOf(
"type" to documentType,
"isPublic" to isPublic.toString(),
"uploadedAt" to System.currentTimeMillis().toString()
)
)
}suspend fun uploadAttachment(fileName: String, fileData: ByteArray) {
DevengNetworkingModule.sendRequest<Unit, Unit>(
endpoint = "/attachments",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = fileName,
fileContent = fileData,
fileFieldName = "attachment" // Custom field name instead of default "File"
)
}data class UploadResponse(val fileId: String, val downloadUrl: String)
suspend fun uploadAndGetUrl(fileName: String, fileData: ByteArray): UploadResponse {
return DevengNetworkingModule.sendRequest<Unit, UploadResponse>(
endpoint = "/files/upload",
requestMethod = DevengHttpMethod.MULTIPART,
fileName = fileName,
fileContent = fileData,
additionalFormFields = mapOf("source" to "mobile")
)
}suspend fun uploadFile(fileName: String, fileData: ByteArray): UploadResponse {
return DevengNetworkingModule.sendRequest(
endpoint = "/files/upload",
requestMethod = DevengHttpMethod.MULTIPART,
requestSerializer = null,
responseSerializer = UploadResponse.serializer(),
fileName = fileName,
fileContent = fileData
)
}Multipart File Upload Features:
val connection = DevengNetworkingModule.connectToWebSocket(
endpoint = "/chat",
onConnected = {
sendMessage("Hello Server!")
},
onMessageReceived = { message ->
println("Received: $message")
},
onError = { error ->
println("WebSocket Error: $error")
},
onClose = {
println("Connection closed")
}
)
// Send messages
connection.sendMessage("Hello World!")scope.launch {
connection.connectionState.collect { state ->
when (state) {
is ConnectionState.Connected -> println("✅ Connected")
is ConnectionState.Connecting -> println("🔄 Connecting...")
is ConnectionState.Disconnected -> println("❌ Disconnected")
is ConnectionState.Error -> println("⚠️ Error: ${state.error}")
}
}
}// Configure max connections (default: 5)
WebSocketConnection.setMaxConnections(3)
// Get connection stats
val activeConnections = WebSocketConnection.getActiveConnections()
val connectionCount = WebSocketConnection.getConnectionCount()
// Close specific connection
DevengNetworkingModule.closeWebSocketConnection("/chat")
// Close all connections
DevengNetworkingModule.closeAllWebSocketConnections()try {
val user = DevengNetworkingModule.sendRequest<Unit, User?>(
endpoint = "/users/123",
requestMethod = DevengHttpMethod.GET
)
} catch (e: DevengException) {
println("Error: ${e.message}") // Localized error message
}class CustomExceptionHandler : ExceptionHandler {
override var locale: Locale = Locale.EN
override fun handleHttpException(
errorMessage: String?,
status: HttpStatusCode
): DevengUiError {
// Custom HTTP error logic
return when (status.value) {
404 -> DevengUiError("Resource not found")
500 -> DevengUiError("Server error occurred")
else -> DevengUiError("Request failed: $errorMessage")
}
}
override fun handleNetworkException(cause: Throwable): DevengUiError {
// Custom network error logic
return DevengUiError("Network connection failed")
}
}// Update authentication token at runtime
DevengNetworkingModule.setToken("new-auth-token")When the token is blank (""), DNM omits the Authorization header entirely rather than sending an empty Authorization: Bearer value — useful for cookie-only auth flows.
Install Ktor's HttpCookies plugin with a custom CookiesStorage to persist cookies across requests (and across process restarts, if your storage writes to disk):
val config = DevengNetworkingConfig(
httpClientConfig = {
install(HttpCookies) {
storage = MyPersistentCookiesStorage()
}
},
wasmJsIncludeCredentials = true // Required on wasmJs for cross-origin cookies
)httpClientConfig is a generic Ktor HttpClient extension point — install any plugin you need, not just HttpCookies. wasmJsIncludeCredentials is engine-level and only relevant on the wasmJs target.
Attach a CSRF token header on every POST, PUT, PATCH, DELETE. The provider is called once per mutating request; a null return skips the header for that request:
val config = DevengNetworkingConfig(
csrfTokenProvider = DevengCsrfTokenProvider { myCsrfStore.getToken() },
csrfHeaderName = "X-CSRF-TOKEN" // Optional - defaults to "X-CSRF-TOKEN"
)Two opt-in hooks fire on HTTP 401. sessionRefresher runs first and can recover the session transparently by refreshing and retrying the original request; onUnauthorized fires for every response that is still 401 after that:
val config = DevengNetworkingConfig(
sessionRefresher = DevengSessionRefresher {
try {
val tokens = authService.refreshSession()
devengNetworkingModule.setToken(tokens.accessToken)
true // Retry the failed request
} catch (e: Exception) {
false // Surface UnauthorizedError
}
},
onUnauthorized = { eventBus.emit(SessionExpired) },
refreshTimeoutMillis = 30_000L // Optional - defaults to 30s, <= 0 disables it
)Concurrent 401s are deduplicated by a per-client mutex + generation counter, so only one refresh runs even under heavy parallel load. The refresher can issue HTTP calls through the same DNM client without infinite recursion — an internal coroutine-context marker suppresses re-entrance.
A few consequences worth knowing:
setToken itself. The retry reads the current token and rewrites the Authorization header, so a refresh that does not publish the new token replays the expired one.refreshTimeoutMillis caps a single refresh() call. The single-flight lock is held for its duration, so without a timeout a stalled refresh endpoint blocks every other request that hits a 401.GlobalScope.launch, viewModelScope.launch) drops the re-entrance marker and lets the refresh request trigger another refresh.onUnauthorized fires per failing request, including when no sessionRefresher is configured. With several requests in flight it fires several times, so debounce it on the consumer side.Add to libs.versions.toml:
[versions]
devengNetworkingKmp = "latest"
[libraries]
deveng-networking-kmp = {
module = "global.deveng:networking-kmp",
version.ref = "devengNetworkingKmp"
}Then in build.gradle.kts:
dependencies {
implementation(libs.deveng.networking.kmp)
}The library is built on top of Ktor and provides:
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
We welcome contributions! Please:
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)For major changes, please open an issue first to discuss your proposal.
Created by Deveng Group - Building powerful, developer-friendly tools for the Kotlin ecosystem.
Made with ❤️ by the Deveng Group team