Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class KotlinMultiplatformLibraryConventionPlugin : Plugin<Project> {
addMacosTargets = true,
addWatchosTargets = path == ":filekit-core",
addMingwTargets = path == ":filekit-core" || path == ":filekit-dialogs",
addLinuxTargets = path == ":filekit-core",
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal fun Project.configureKotlinMultiplatform(
addMacosTargets: Boolean,
addWatchosTargets: Boolean,
addMingwTargets: Boolean = false,
addLinuxTargets: Boolean = false,
) = extension.apply {
// Force visibility of public API
explicitApi()
Expand Down Expand Up @@ -47,6 +48,12 @@ internal fun Project.configureKotlinMultiplatform(
mingwX64()
}

// Linux native targets
if (addLinuxTargets) {
linuxX64()
linuxArm64()
}

// If one day we need to disable watchOS tests
//
// if (addWatchosTargets) {
Expand Down
1 change: 1 addition & 0 deletions filekit-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ kotlin {
jvmMain.get().dependsOn(desktopMain)
macosMain.get().dependsOn(desktopMain)
mingwX64Main.get().dependsOn(desktopMain)
findByName("linuxMain")?.dependsOn(desktopMain)
jvmMain.get().dependsOn(jvmAndNativeMain)
nativeMain.get().dependsOn(jvmAndNativeMain)

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

import io.github.vinceglb.filekit.exceptions.FileKitException
import io.github.vinceglb.filekit.utils.runSuspendCatchingFileKit
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.withContext
import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.readString
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.cinterop.toKString
import platform.posix.getenv

public actual object FileKit {
private var _appId: String? = null
internal var customCacheDir: Path? = null
internal var customFilesDir: Path? = null

public val appId: String
get() = _appId
?: throw FileKitException("FileKit not initialized. Please call FileKit.init(appId) first.")

public fun init(appId: String) {
_appId = appId
customCacheDir = null
customFilesDir = null
}

public fun init(
filesDir: PlatformFile,
cacheDir: PlatformFile,
) {
_appId = null
customCacheDir = cacheDir.toKotlinxIoPath()
customFilesDir = filesDir.toKotlinxIoPath()
}

public fun init(
appId: String,
filesDir: PlatformFile? = null,
cacheDir: PlatformFile? = null,
) {
_appId = appId
customCacheDir = cacheDir?.toKotlinxIoPath()
customFilesDir = filesDir?.toKotlinxIoPath()
}
}

public actual val FileKit.filesDir: PlatformFile
get() {
val folder = FileKit.customFilesDir
?: (getEnv("XDG_DATA_HOME")?.let { Path(it) } ?: (getEnv("HOME")?.let { Path(it, ".local/share") } ?: Path(".local/share"))) / FileKit.appId
folder.assertExists()
return PlatformFile(folder)
}

public actual val FileKit.cacheDir: PlatformFile
get() {
val folder = FileKit.customCacheDir
?: (getEnv("XDG_CACHE_HOME")?.let { Path(it) } ?: (getEnv("HOME")?.let { Path(it, ".cache") } ?: Path(".cache"))) / FileKit.appId
folder.assertExists()
return PlatformFile(folder)
}

public actual val FileKit.databasesDir: PlatformFile
get() = FileKit.filesDir / "databases"

public actual val FileKit.projectDir: PlatformFile
get() = PlatformFile(".")

@OptIn(ExperimentalForeignApi::class)
internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirectory): PlatformFile? {
val home = getEnv("HOME") ?: return null

val envKey = when (type) {
FileKitUserDirectory.Downloads -> "XDG_DOWNLOAD_DIR"
FileKitUserDirectory.Pictures -> "XDG_PICTURES_DIR"
FileKitUserDirectory.Videos -> "XDG_VIDEOS_DIR"
FileKitUserDirectory.Music -> "XDG_MUSIC_DIR"
FileKitUserDirectory.Documents -> "XDG_DOCUMENTS_DIR"
}

val fallbackName = when (type) {
FileKitUserDirectory.Downloads -> "Downloads"
FileKitUserDirectory.Pictures -> "Pictures"
FileKitUserDirectory.Videos -> "Videos"
FileKitUserDirectory.Music -> "Music"
FileKitUserDirectory.Documents -> "Documents"
}

val envValue = getEnv(envKey)?.takeIf(String::isNotBlank)?.let { expandHomeVariable(it, home) }
if (envValue != null) {
val path = Path(envValue)
path.assertExists()
return PlatformFile(path)
}

// Try reading ~/.config/user-dirs.dirs for the actual config (simplistic parsing)
val configHome = getEnv("XDG_CONFIG_HOME")?.takeIf(String::isNotBlank) ?: "$home/.config"
val configFile = Path(configHome, "user-dirs.dirs")
if (SystemFileSystem.exists(configFile)) {
val content = runCatching { SystemFileSystem.source(configFile).buffered().use { it.readString() } }.getOrNull()
if (content != null) {
val configuredValue = parseXdgUserDirsConfig(content)[envKey]?.takeIf(String::isNotBlank)?.let { expandHomeVariable(it, home) }
if (configuredValue != null) {
val path = Path(configuredValue)
path.assertExists()
return PlatformFile(path)
}
}
}

val fallbackPath = Path(home, fallbackName)
fallbackPath.assertExists()
return PlatformFile(fallbackPath)
}

public actual suspend fun FileKit.saveImageToGallery(
bytes: ByteArray,
filename: String,
): Result<Unit> = runSuspendCatchingFileKit {
FileKit.picturesDir / filename write bytes
}

public actual suspend fun FileKit.saveVideoToGallery(
file: PlatformFile,
filename: String,
): Result<Unit> = runSuspendCatchingFileKit {
FileKit.videosDir / filename write file
}

public actual suspend fun FileKit.compressImage(
bytes: ByteArray,
imageFormat: ImageFormat,
@androidx.annotation.IntRange(from = 0, to = 100) quality: Int,
maxWidth: Int?,
maxHeight: Int?,
): ByteArray =
throw FileKitException("Image compression is not supported on Linux native target")

@OptIn(ExperimentalForeignApi::class)
private fun getEnv(key: String): String? =
getenv(key)?.toKString()

private operator fun Path.div(child: String): Path = Path(this, child)

private fun Path.assertExists() {
if (!SystemFileSystem.exists(this)) {
SystemFileSystem.createDirectories(this)
}
}

private fun expandHomeVariable(path: String, home: String): String =
path
.replace("\${HOME}", home)
.replace("\$HOME", home)

private fun parseXdgUserDirsConfig(config: String): Map<String, String> =
buildMap {
config
.lineSequence()
.map(String::trim)
.filter(String::isNotBlank)
.filterNot { it.startsWith("#") }
.forEach { line ->
val key = line.substringBefore("=", missingDelimiterValue = "").trim()
val rawValue = line.substringAfter("=", missingDelimiterValue = "").trim()

if (key.isBlank() || rawValue.isBlank()) {
return@forEach
}

val value = rawValue
.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")

put(key, value)
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package io.github.vinceglb.filekit

import io.github.vinceglb.filekit.mimeType.MimeType
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.serialization.Serializable
import platform.posix.stat
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

/**
* Wrapper for a file path on Linux platform.
*/
public class LinuxPath(
public val path: Path,
)

/**
* Represents a file on the Linux platform.
*
* @property linuxPath The underlying wrapped [Path] object.
*/
@Serializable(with = PlatformFileSerializer::class)
public actual class PlatformFile(
public val linuxPath: LinuxPath,
) {
public actual override fun toString(): String = linuxPath.path.toString()

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PlatformFile) return false
return linuxPath.path.toString() == other.linuxPath.path.toString()
}

override fun hashCode(): Int = linuxPath.path.toString().hashCode()

public actual companion object
}

public actual fun PlatformFile(path: Path): PlatformFile =
PlatformFile(linuxPath = LinuxPath(path))

public actual fun PlatformFile.toKotlinxIoPath(): Path =
linuxPath.path

public actual val PlatformFile.extension: String
get() = name.substringAfterLast('.', "")

public actual val PlatformFile.nameWithoutExtension: String
get() = name.substringBeforeLast('.', name)

public actual fun PlatformFile.absolutePath(): String {
val rawPath = linuxPath.path.toString()
if (rawPath.startsWith("/")) return rawPath
return absoluteFile().linuxPath.path.toString()
}

public actual inline fun PlatformFile.list(block: (List<PlatformFile>) -> Unit): Unit =
withScopedAccess {
val directoryFiles = SystemFileSystem
.list(toKotlinxIoPath())
.map { PlatformFile(it) }
block(directoryFiles)
}

public actual fun PlatformFile.list(): List<PlatformFile> =
withScopedAccess {
SystemFileSystem
.list(toKotlinxIoPath())
.map { PlatformFile(it) }
}

@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class)
public actual fun PlatformFile.createdAt(): Instant? = memScoped {
val statBuf = alloc<stat>()
if (platform.posix.stat(absolutePath(), statBuf.ptr) == 0) {
Instant.fromEpochSeconds(statBuf.st_ctim.tv_sec.toLong(), statBuf.st_ctim.tv_nsec.toLong())
} else {
null
}
}

@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class)
public actual fun PlatformFile.lastModified(): Instant = memScoped {
val statBuf = alloc<stat>()
if (platform.posix.stat(absolutePath(), statBuf.ptr) == 0) {
Instant.fromEpochSeconds(statBuf.st_mtim.tv_sec.toLong(), statBuf.st_mtim.tv_nsec.toLong())
} else {
Instant.fromEpochMilliseconds(0L)
}
}

public actual fun PlatformFile.mimeType(): MimeType? = null

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

public actual fun PlatformFile.stopAccessingSecurityScopedResource() {}

public actual suspend fun PlatformFile.bookmarkData(): BookmarkData =
withContext(Dispatchers.IO) {
BookmarkData(absolutePath().encodeToByteArray())
}

public actual fun PlatformFile.releaseBookmark() {}

public actual fun PlatformFile.Companion.fromBookmarkData(
bookmarkData: BookmarkData,
): PlatformFile {
val restoredPath = bookmarkData.bytes.decodeToString()
return PlatformFile(Path(restoredPath))
}
Loading