Skip to content
Merged
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
15 changes: 14 additions & 1 deletion docs/core/platform-file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ val absolutePath: String = file.absolutePath()

// Get the absolute file
val absoluteFile: PlatformFile = file.absoluteFile()

// Check the MIME type (null for directories or unknown types)
val mimeType: MimeType? = file.mimeType()
```

## File operations
Expand Down Expand Up @@ -145,6 +148,16 @@ file.write(bytes)
file.writeString(content)
```

### Determining MIME types

Use `mimeType()` to ask the underlying platform for the best-known media type. It returns `null` when the value is unknown (for example, directories or proprietary containers).

- **Apple platforms**: FileKit first queries provider metadata (e.g., from the Files app or iCloud Drive) and falls back to the filename extension when needed, so even extension-less documents can resolve to a MIME type.
- **Android**: The resolver consults the `ContentResolver` for `content://` URIs and then falls back to `MimeTypeMap` if necessary.
- **Desktop & Web**: The lookup is derived from the file extension.

This utility is helpful when you need to validate file types, populate upload headers, or customise previews based on content.

## Working with directories

You can use the `resolve` method or the `/` operator to navigate through directories:
Expand Down Expand Up @@ -240,4 +253,4 @@ println(content) // "Hello, FileKit!"
Maintain persistent access to user-selected files across app restarts.
</Card>

</CardGroup>
</CardGroup>
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.documentfile.provider.DocumentFile
import io.github.vinceglb.filekit.exceptions.FileKitException
import io.github.vinceglb.filekit.exceptions.FileKitUriPathNotSupportedException
import io.github.vinceglb.filekit.mimeType.MimeType
import io.github.vinceglb.filekit.utils.toKotlinxPath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -151,6 +153,39 @@ public actual fun PlatformFile.absoluteFile(): PlatformFile = when (androidFile)
is AndroidFile.UriWrapper -> this
}

public actual fun PlatformFile.mimeType(): MimeType? {
if (isDirectory()) {
return null
}

return when (androidFile) {
is AndroidFile.FileWrapper -> {
val mimeTypeValue = getMimeTypeValueFromExtension(extension)
mimeTypeValue?.let(MimeType::parse)
}

is AndroidFile.UriWrapper -> {
val mimeTypeValueFromContentResolver =
FileKit.context.contentResolver.getType(androidFile.uri)
val mimeTypeValue =
mimeTypeValueFromContentResolver ?: getMimeTypeValueFromExtension(extension)
mimeTypeValue?.let(MimeType::parse)
}
}
}

private fun getMimeTypeValueFromExtension(extension: String): String? {
val safeExtension = extension.trim().lowercase()

if (safeExtension.isEmpty()) return null

return MimeTypeMap
.getSingleton()
.getMimeTypeFromExtension(safeExtension)
?.trim()
?.lowercase()
}

public actual fun PlatformFile.source(): RawSource = when (androidFile) {
is AndroidFile.FileWrapper -> SystemFileSystem.source(toKotlinxIoPath())

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
package io.github.vinceglb.filekit

import io.github.vinceglb.filekit.exceptions.FileKitException
import io.github.vinceglb.filekit.mimeType.MimeType
import io.github.vinceglb.filekit.utils.toByteArray
import io.github.vinceglb.filekit.utils.toKotlinxPath
import io.github.vinceglb.filekit.utils.toNSData
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.pointed
import kotlinx.cinterop.ptr
import kotlinx.cinterop.toKString
import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import platform.CoreFoundation.CFRelease
import platform.CoreFoundation.CFStringCreateWithCString
import platform.CoreFoundation.CFStringGetCString
import platform.CoreFoundation.CFStringGetLength
import platform.CoreFoundation.CFStringGetMaximumSizeForEncoding
import platform.CoreFoundation.CFStringRef
import platform.CoreFoundation.kCFAllocatorDefault
import platform.CoreFoundation.kCFStringEncodingUTF8
import platform.CoreServices.UTTypeCopyPreferredTagWithClass
import platform.CoreServices.kUTTagClassMIMEType
import platform.Foundation.NSError
import platform.Foundation.NSURL
import platform.Foundation.NSURLContentTypeKey
import platform.Foundation.NSURLResourceKey
import platform.Foundation.NSURLTypeIdentifierKey
import platform.UniformTypeIdentifiers.UTType

public actual data class PlatformFile(
val nsUrl: NSURL,
Expand Down Expand Up @@ -74,6 +92,94 @@ public actual fun PlatformFile.list(): List<PlatformFile> =
.map { PlatformFile(NSURL.fileURLWithPath(it.toString())) }
}

public actual fun PlatformFile.mimeType(): MimeType? = withScopedAccess { file ->
file.nsUrl.mimeTypeFromMetadata()
?: file.nsUrl.mimeTypeFromExtension()
}?.takeIf { it.isNotBlank() }?.let(MimeType::parse)

private fun NSURL.mimeTypeFromMetadata(): String? {
val contentType = resourceValue(NSURLContentTypeKey) as? UTType

val fromContentType = contentType?.preferredMIMEType
if (!fromContentType.isNullOrBlank()) {
return fromContentType
}

val identifier = contentType?.identifier
?: resourceValue(NSURLTypeIdentifierKey) as? String

return identifier?.let(::mimeTypeFromUti)
}

private fun NSURL.mimeTypeFromExtension(): String? =
pathExtension
?.takeIf { it.isNotBlank() }
?.let { ext -> UTType.typeWithFilenameExtension(ext)?.preferredMIMEType }

@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun NSURL.resourceValue(key: NSURLResourceKey?): Any? = memScoped {
val valuePtr = alloc<ObjCObjectVar<Any?>>()
val success = getResourceValue(value = valuePtr.ptr, forKey = key, error = null)
if (success) valuePtr.value else null
}

@OptIn(ExperimentalForeignApi::class)
private fun mimeTypeFromUti(uti: String): String? {
if (uti.isBlank()) {
return null
}

return memScoped {
val cfUti = CFStringCreateWithCString(
alloc = kCFAllocatorDefault,
cStr = uti,
encoding = kCFStringEncodingUTF8
) ?: return@memScoped null

val mimeRef = UTTypeCopyPreferredTagWithClass(
inUTI = cfUti,
inTagClass = kUTTagClassMIMEType
)

CFRelease(cfUti)

val mimeValue = cfStringToKString(mimeRef)

mimeRef?.let { CFRelease(it) }

mimeValue
}
}

@OptIn(ExperimentalForeignApi::class)
private fun cfStringToKString(cfString: CFStringRef?): String? {
if (cfString == null) {
return null
}

val length = CFStringGetLength(cfString)

val maxSize = CFStringGetMaximumSizeForEncoding(
length = length,
encoding = kCFStringEncodingUTF8
) + 1

return memScoped {
val buffer = allocArray<ByteVar>(maxSize.toInt())

if (
CFStringGetCString(
theString = cfString,
buffer = buffer,
bufferSize = maxSize,
encoding = kCFStringEncodingUTF8
)
) {
buffer.toKString()
} else null
}
}

public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean =
nsUrl.startAccessingSecurityScopedResource()

Expand Down Expand Up @@ -109,4 +215,4 @@ public actual fun PlatformFile.Companion.fromBookmarkData(
) ?: throw FileKitException("Failed to resolve bookmark data: ${error.pointed.value}")

PlatformFile(restoredUrl)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.github.vinceglb.filekit

import io.github.vinceglb.filekit.mimeType.MimeType

public expect class PlatformFile {
override fun toString(): String
public companion object
Expand All @@ -17,6 +19,8 @@ public expect suspend fun PlatformFile.readBytes(): ByteArray

public expect suspend fun PlatformFile.readString(): String

public expect fun PlatformFile.mimeType(): MimeType?

public expect fun PlatformFile.startAccessingSecurityScopedResource(): Boolean

public expect fun PlatformFile.stopAccessingSecurityScopedResource()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.github.vinceglb.filekit.exceptions

public class InvalidMimeTypeException : FileKitException {
public constructor(message: String) : super(message)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package io.github.vinceglb.filekit.mimeType

import io.github.vinceglb.filekit.exceptions.InvalidMimeTypeException

public class MimeType private constructor(
public val primaryType: String,
public val subtype: String,
public val parameters: Set<MimeTypeParameter> = emptySet()
) {

init {
if (primaryType.isBlank()) {
throw InvalidMimeTypeException("MIME type primary type must not be blank")
}

if (subtype.isBlank()) {
throw InvalidMimeTypeException("MIME type subtype must not be blank")
}
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MimeType) return false

if (!primaryType.equals(other.primaryType, ignoreCase = true)) return false
if (!subtype.equals(other.subtype, ignoreCase = true)) return false

if (parameters.size != other.parameters.size) return false

val thisParams = parameters.associateBy { it.name.lowercase() }
val otherParams = other.parameters.associateBy { it.name.lowercase() }

if (thisParams.keys != otherParams.keys) return false

return thisParams.all { (key, param) ->
val otherParam = otherParams[key]
otherParam != null && param.value == otherParam.value
}
}

override fun hashCode(): Int {
val typeHash = primaryType.lowercase().hashCode()
val subHash = subtype.lowercase().hashCode()
val paramsHash = parameters
.associateBy(keySelector = { it.name.lowercase() }, valueTransform = { it.value })
.hashCode()
return 31 * (31 * typeHash + subHash) + paramsHash
}

override fun toString(): String {
val base = "$primaryType/$subtype"

if (parameters.isEmpty()) return base

return buildString {
append(base)

parameters.forEach { parameter ->
append("; ")
append(parameter.name)
append("=")
append(parameter.value)
}
}
}

public companion object {
public fun parse(mimeType: String): MimeType {
val parts = mimeType.split(";").map { it.trim() }

if (parts.isEmpty() || parts[0].isEmpty()) {
throw InvalidMimeTypeException("MIME type string must not be empty")
}

val typeParts = parts[0].split("/")

if (typeParts.size != 2) {
throw InvalidMimeTypeException("Invalid MIME type format: $mimeType")
}

val primaryType = typeParts[0].lowercase()
val subtype = typeParts[1].lowercase()

val parameters = if (parts.size > 1) {
parts.drop(1).map { part ->
val nameValuePair = part.split("=")

if (nameValuePair.size == 2) {
val name = nameValuePair[0].lowercase()
val value = nameValuePair[0].lowercase()
MimeTypeParameter(name = name, value = value)
} else {
throw InvalidMimeTypeException("Invalid parameter in MIME type: $part")
}
}.toSet()
} else emptySet()

return MimeType(primaryType = primaryType, subtype = subtype, parameters = parameters)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.vinceglb.filekit.mimeType

public data class MimeTypeParameter(
val name: String,
val value: String
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.vinceglb.filekit

import io.github.vinceglb.filekit.exceptions.FileKitException
import io.github.vinceglb.filekit.mimeType.MimeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.khronos.webgl.ArrayBuffer
Expand Down Expand Up @@ -65,3 +66,8 @@ public actual suspend fun PlatformFile.readBytes(): ByteArray = withContext(Disp
reader.readAsArrayBuffer(file)
}
}

public actual fun PlatformFile.mimeType(): MimeType? =
takeIf { file.type.isNotBlank() }
?.let { MimeType.parse(file.type) }

Loading