diff --git a/docs/core/platform-file.mdx b/docs/core/platform-file.mdx index 8c2a46d6..4298b016 100644 --- a/docs/core/platform-file.mdx +++ b/docs/core/platform-file.mdx @@ -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 @@ -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: @@ -240,4 +253,4 @@ println(content) // "Hello, FileKit!" Maintain persistent access to user-selected files across app restarts. - \ No newline at end of file + diff --git a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt index d3223855..373058af 100644 --- a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt +++ b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt @@ -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 @@ -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()) diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index e525c09f..b760d252 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -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, @@ -74,6 +92,94 @@ public actual fun PlatformFile.list(): List = .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>() + 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(maxSize.toInt()) + + if ( + CFStringGetCString( + theString = cfString, + buffer = buffer, + bufferSize = maxSize, + encoding = kCFStringEncodingUTF8 + ) + ) { + buffer.toKString() + } else null + } +} + public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = nsUrl.startAccessingSecurityScopedResource() @@ -109,4 +215,4 @@ public actual fun PlatformFile.Companion.fromBookmarkData( ) ?: throw FileKitException("Failed to resolve bookmark data: ${error.pointed.value}") PlatformFile(restoredUrl) -} \ No newline at end of file +} diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt index e75b3c5d..b2efdb87 100644 --- a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt @@ -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 @@ -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() diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/exceptions/InvalidMimeTypeException.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/exceptions/InvalidMimeTypeException.kt new file mode 100644 index 00000000..6a080d3a --- /dev/null +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/exceptions/InvalidMimeTypeException.kt @@ -0,0 +1,5 @@ +package io.github.vinceglb.filekit.exceptions + +public class InvalidMimeTypeException : FileKitException { + public constructor(message: String) : super(message) +} \ No newline at end of file diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/mimeType/MimeType.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/mimeType/MimeType.kt new file mode 100644 index 00000000..40bda8d1 --- /dev/null +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/mimeType/MimeType.kt @@ -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 = 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) + } + } +} diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/mimeType/MimeTypeParameter.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/mimeType/MimeTypeParameter.kt new file mode 100644 index 00000000..d7183898 --- /dev/null +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/mimeType/MimeTypeParameter.kt @@ -0,0 +1,6 @@ +package io.github.vinceglb.filekit.mimeType + +public data class MimeTypeParameter( + val name: String, + val value: String +) diff --git a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt b/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt index f6491fc2..3687ff03 100644 --- a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt +++ b/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt @@ -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 @@ -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) } + diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index 2b9391ce..f8f8da77 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -1,5 +1,6 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.mimeType.MimeType import io.github.vinceglb.filekit.utils.toFile import io.github.vinceglb.filekit.utils.toKotlinxIoPath import kotlinx.coroutines.Dispatchers @@ -7,6 +8,7 @@ import kotlinx.coroutines.withContext import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import java.io.File +import java.nio.file.Files public actual data class PlatformFile( val file: File, @@ -42,6 +44,16 @@ public actual fun PlatformFile.list(): List = SystemFileSystem.list(toKotlinxIoPath()).map(::PlatformFile) } +public actual fun PlatformFile.mimeType(): MimeType? { + val mimeTypeValue = try { + Files.probeContentType(file.toPath()) + } catch (_: Exception) { + null + } + + return mimeTypeValue?.let(MimeType::parse) +} + public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true public actual fun PlatformFile.stopAccessingSecurityScopedResource() {} diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt index 660347c7..eba2d03d 100644 --- a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt +++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt @@ -1,5 +1,6 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.mimeType.MimeType import kotlinx.coroutines.test.runTest import kotlinx.io.IOException import kotlinx.io.files.FileNotFoundException @@ -183,4 +184,28 @@ class PlatformFileNonWebTest { assertEquals(expected = notExistingFile.path, actual = notExistingFile.toString()) assertEquals(expected = resourceDirectory.path, actual = resourceDirectory.toString()) } + + @Test + fun testPlatformMimeType() { + assertEquals( + expected = MimeType.parse("text/plain"), + actual = textFile.mimeType() + ) + assertEquals( + expected = MimeType.parse("image/png"), + actual = imageFile.mimeType() + ) + assertEquals( + expected = null, + actual = emptyFile.mimeType() + ) + assertEquals( + expected = MimeType.parse("application/pdf"), + actual = notExistingFile.mimeType() + ) + assertEquals( + expected = null, + actual = resourceDirectory.mimeType() + ) + } } diff --git a/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt b/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt index 16857e32..aa79088c 100644 --- a/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt +++ b/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt @@ -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 @@ -65,3 +66,7 @@ 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) } diff --git a/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt b/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt index ef5c594d..78d76e8b 100644 --- a/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt +++ b/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt @@ -1,5 +1,6 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.mimeType.MimeType import io.github.vinceglb.filekit.utils.createTestFile import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -64,4 +65,12 @@ class PlatformFileWebTest { actual = bytes.decodeToString(), ) } + + @Test + fun testPlatformMimeType() { + assertEquals( + expected = MimeType.parse("text/plain"), + actual = platformFile.mimeType(), + ) + } }