From 8871b4ba763fbb7526e905ff91f376c63b8bf4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Mon, 13 Oct 2025 11:33:11 +0200 Subject: [PATCH 1/6] feat(core-file): add core file module for the FileSystemManager # Conflicts: # app-common/build.gradle.kts # app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt --- app-common/build.gradle.kts | 1 + .../app/common/core/AppCommonCoreModule.kt | 9 +++ .../app/common/core/logging/LoggerModule.kt | 3 +- core/file/build.gradle.kts | 15 +++++ .../core}/file/AndroidFileSystemManager.kt | 9 ++- .../core/file/AndroidFileSystemManagerTest.kt | 55 +++++++++++++++++++ .../core}/file/FileSystemManager.kt | 11 +++- .../core/file/JvmFileSystemManager.kt | 36 ++++++++++++ .../core/file/JvmFileSystemManagerTest.kt | 45 +++++++++++++++ core/logging/impl-file/build.gradle.kts | 1 + .../core/logging/file/AndroidFileLogSink.kt | 1 + .../core/logging/file/FileLogSink.android.kt | 1 + .../logging/file/FakeFileSystemManager.kt | 7 +++ .../core/logging/file/FileLogSink.kt | 1 + .../core/logging/file/FileLogSink.jvm.kt | 9 +-- .../core/logging/file/JvmFileSystemManager.kt | 13 ----- settings.gradle.kts | 1 + 17 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 core/file/build.gradle.kts rename core/{logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging => file/src/androidMain/kotlin/net/thunderbird/core}/file/AndroidFileSystemManager.kt (67%) create mode 100644 core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt rename core/{logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging => file/src/commonMain/kotlin/net/thunderbird/core}/file/FileSystemManager.kt (59%) create mode 100644 core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt create mode 100644 core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt delete mode 100644 core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt diff --git a/app-common/build.gradle.kts b/app-common/build.gradle.kts index 25684d341ef..104d16a85d0 100644 --- a/app-common/build.gradle.kts +++ b/app-common/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.core.configstore.implBackend) implementation(projects.core.featureflag) + implementation(projects.core.file) implementation(projects.core.ui.setting.api) implementation(projects.core.ui.setting.implDialog) diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt index 08d2f946376..67f925ad839 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt @@ -1,8 +1,11 @@ package net.thunderbird.app.common.core +import android.content.Context import net.thunderbird.app.common.core.configstore.appCommonCoreConfigStoreModule import net.thunderbird.app.common.core.logging.appCommonCoreLogger import net.thunderbird.app.common.core.ui.appCommonCoreUiModule +import net.thunderbird.core.file.AndroidFileSystemManager +import net.thunderbird.core.file.FileSystemManager import org.koin.core.module.Module import org.koin.dsl.module @@ -12,4 +15,10 @@ val appCommonCoreModule: Module = module { appCommonCoreLogger, appCommonCoreUiModule, ) + + single { + AndroidFileSystemManager( + contentResolver = get().contentResolver, + ) + } } diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt index 2dfe2abe1c8..7e90d58c86c 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt @@ -13,7 +13,6 @@ import net.thunderbird.core.logging.LogSink import net.thunderbird.core.logging.Logger import net.thunderbird.core.logging.composite.CompositeLogSink import net.thunderbird.core.logging.console.ConsoleLogSink -import net.thunderbird.core.logging.file.AndroidFileSystemManager import net.thunderbird.core.logging.file.FileLogSink import org.koin.core.qualifier.named import org.koin.dsl.bind @@ -64,7 +63,7 @@ val appCommonCoreLogger = module { level = LogLevel.DEBUG, fileName = "thunderbird-sync-debug", fileLocation = get().filesDir.path, - fileSystemManager = AndroidFileSystemManager(get().contentResolver), + fileSystemManager = get(), ) } diff --git a/core/file/build.gradle.kts b/core/file/build.gradle.kts new file mode 100644 index 00000000000..b14699cc738 --- /dev/null +++ b/core/file/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.file" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.io.core) + } + } +} diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt similarity index 67% rename from core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt rename to core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt index fe61e2ec87c..7473969fa0b 100644 --- a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt +++ b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt @@ -1,10 +1,12 @@ -package net.thunderbird.core.logging.file +package net.thunderbird.core.file import android.content.ContentResolver import android.net.Uri import androidx.core.net.toUri import kotlinx.io.RawSink +import kotlinx.io.RawSource import kotlinx.io.asSink +import kotlinx.io.asSource /** * Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations. @@ -16,4 +18,9 @@ class AndroidFileSystemManager( val uri: Uri = uriString.toUri() return contentResolver.openOutputStream(uri, mode)?.asSink() } + + override fun openSource(uriString: String): RawSource? { + val uri: Uri = uriString.toUri() + return contentResolver.openInputStream(uri)?.asSource() + } } diff --git a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt new file mode 100644 index 00000000000..1e750979bb3 --- /dev/null +++ b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt @@ -0,0 +1,55 @@ +package net.thunderbird.core.file + +import android.content.Context +import android.net.Uri +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.io.Buffer +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AndroidFileSystemManagerTest { + + private val appContext: Context = RuntimeEnvironment.getApplication() + + private val testSubject = AndroidFileSystemManager(appContext.contentResolver) + + @JvmField + @Rule + val folder = TemporaryFolder() + + @Test + fun openSinkAndOpenSource_writeAndReadFileContentRoundtrip() { + // Arrange + val tempFile = folder.newFile("tb-file-fs-test-android.txt") + val uri: Uri = Uri.fromFile(tempFile) + val testText = "Hello Thunderbird Android!" + + // Act + val sink = checkNotNull(testSubject.openSink(uri.toString(), "wt")) + val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } + sink.write(writeBuffer, writeBuffer.size) + sink.flush() + sink.close() + + val source = checkNotNull(testSubject.openSource(uri.toString())) + val readBuffer = Buffer() + source.readAtMostTo(readBuffer, 1024) + val bytes = ByteArray(readBuffer.size.toInt()) + for (i in bytes.indices) { + bytes[i] = readBuffer.readByte() + } + val result = bytes.decodeToString() + source.close() + + // Assert + assertThat(result).isEqualTo(testText) + } +} diff --git a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt similarity index 59% rename from core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt rename to core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt index aaca95a03dc..82c03bb1cae 100644 --- a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt @@ -1,6 +1,7 @@ -package net.thunderbird.core.logging.file +package net.thunderbird.core.file import kotlinx.io.RawSink +import kotlinx.io.RawSource /** * An interface for file system operations that are platform-specific. @@ -14,4 +15,12 @@ interface FileSystemManager { * @return A sink for writing to the URI, or null if the URI couldn't be opened */ fun openSink(uriString: String, mode: String): RawSink? + + /** + * Opens a source for reading from a URI. + * + * @param uriString The URI string to open a source for + * @return A source for reading from the URI, or null if the URI couldn't be opened + */ + fun openSource(uriString: String): RawSource? } diff --git a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt new file mode 100644 index 00000000000..4d9766aac0a --- /dev/null +++ b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt @@ -0,0 +1,36 @@ +package net.thunderbird.core.file + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlinx.io.RawSink +import kotlinx.io.RawSource +import kotlinx.io.asSink +import kotlinx.io.asSource + +/** + * JVM implementation of [FileSystemManager] using java.io streams. + */ +class JvmFileSystemManager : FileSystemManager { + override fun openSink(uriString: String, mode: String): RawSink? { + // Only support simple file paths for JVM implementation + return try { + val file = File(uriString) + // create parent directories if necessary + file.parentFile?.mkdirs() + val append = mode.contains("a") // crude check for append mode + FileOutputStream(file, append).asSink() + } catch (_: Throwable) { + null + } + } + + override fun openSource(uriString: String): RawSource? { + return try { + val file = File(uriString) + FileInputStream(file).asSource() + } catch (_: Throwable) { + null + } + } +} diff --git a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt new file mode 100644 index 00000000000..a95daba68bd --- /dev/null +++ b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt @@ -0,0 +1,45 @@ +package net.thunderbird.core.file + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.File +import kotlinx.io.Buffer +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class JvmFileSystemManagerTest { + + private val testSubject = JvmFileSystemManager() + + @JvmField + @Rule + val folder = TemporaryFolder() + + @Test + fun `openSink and openSource should write and read file content roundtrip`() { + // Arrange + val tempFile: File = folder.newFile("tb-file-fs-test.txt") + val testText = "Hello Thunderbird!" + val sink = checkNotNull(testSubject.openSink(tempFile.absolutePath, "wt")) + + // Act + val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } + sink.write(writeBuffer, writeBuffer.size) + sink.flush() + sink.close() + + val source = checkNotNull(testSubject.openSource(tempFile.absolutePath)) + val readBuffer = Buffer() + source.readAtMostTo(readBuffer, 1024) + val bytes = ByteArray(readBuffer.size.toInt()) + for (i in bytes.indices) { + bytes[i] = readBuffer.readByte() + } + val result = bytes.decodeToString() + source.close() + + // Assert + assertThat(result).isEqualTo(testText) + } +} diff --git a/core/logging/impl-file/build.gradle.kts b/core/logging/impl-file/build.gradle.kts index cc2a55ff65b..cecbc493bca 100644 --- a/core/logging/impl-file/build.gradle.kts +++ b/core/logging/impl-file/build.gradle.kts @@ -11,6 +11,7 @@ kotlin { commonMain.dependencies { implementation(libs.kotlinx.io.core) implementation(projects.core.logging.api) + implementation(projects.core.file) } } } diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt index df564228c95..63d2faa0835 100644 --- a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt @@ -18,6 +18,7 @@ import kotlinx.datetime.toLocalDateTime import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.io.asSink +import net.thunderbird.core.file.FileSystemManager import net.thunderbird.core.logging.LogEvent import net.thunderbird.core.logging.LogLevel diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt index dd131394a8f..2f1d1508022 100644 --- a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt @@ -1,5 +1,6 @@ package net.thunderbird.core.logging.file +import net.thunderbird.core.file.FileSystemManager import net.thunderbird.core.logging.LogLevel actual fun FileLogSink( diff --git a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt index cdd9e18a468..5238a7cd2aa 100644 --- a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt +++ b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt @@ -4,6 +4,8 @@ import java.io.ByteArrayOutputStream import java.nio.charset.StandardCharsets import kotlinx.io.Buffer import kotlinx.io.RawSink +import kotlinx.io.RawSource +import net.thunderbird.core.file.FileSystemManager class FakeFileSystemManager : FileSystemManager { @@ -29,4 +31,9 @@ class FakeFileSystemManager : FileSystemManager { override fun close() = Unit } } + + override fun openSource(uriString: String): RawSource? { + // Not needed for tests in this module + return null + } } diff --git a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt index 1a368dd26fd..ffbb97ba73e 100644 --- a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt +++ b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt @@ -1,5 +1,6 @@ package net.thunderbird.core.logging.file +import net.thunderbird.core.file.FileSystemManager import net.thunderbird.core.logging.LogLevel import net.thunderbird.core.logging.LogSink diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt index 2ceb50437de..89a97092832 100644 --- a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt +++ b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt @@ -1,15 +1,8 @@ package net.thunderbird.core.logging.file +import net.thunderbird.core.file.FileSystemManager import net.thunderbird.core.logging.LogLevel -/** - * A [LogSink] implementation that logs messages to a specified internal file. - * - * This sink uses the platform-specific implementations to handle logging. - * - * @param level The minimum [LogLevel] for messages to be logged. - * @param fileName The [String] fileName to log to - */ actual fun FileLogSink( level: LogLevel, fileName: String, diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt deleted file mode 100644 index fb3179fc358..00000000000 --- a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.thunderbird.core.logging.file - -import kotlinx.io.RawSink - -/** - * Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations. - */ -class JvmFileSystemManager() : FileSystemManager { - override fun openSink(uriString: String, mode: String): RawSink? { - // TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9435 - return TODO("Provide the return value") - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index b7dc1062588..4716ae8631b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -163,6 +163,7 @@ include( ":core:logging:impl-legacy", ":core:logging:impl-file", ":core:logging:testing", + ":core:file", ":core:mail:mailserver", ":core:preference:api", ":core:preference:impl", From faab995db47d4e6853f49d55821a0a078f725327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Mon, 13 Oct 2025 11:53:10 +0200 Subject: [PATCH 2/6] feat(core-file): add FileManager with copy support --- .../app/common/core/AppCommonCoreModule.kt | 8 +++ .../app/common/core/logging/LoggerModule.kt | 2 +- .../fossReleaseRuntimeClasspath.txt | 2 + .../fullReleaseRuntimeClasspath.txt | 2 + .../dependencies/fossBetaRuntimeClasspath.txt | 2 + .../fossDailyRuntimeClasspath.txt | 2 + .../fossReleaseRuntimeClasspath.txt | 2 + .../dependencies/fullBetaRuntimeClasspath.txt | 2 + .../fullDailyRuntimeClasspath.txt | 2 + .../fullReleaseRuntimeClasspath.txt | 2 + core/file/build.gradle.kts | 6 ++ .../core/file/DefaultFileManager.kt | 15 +++++ .../net/thunderbird/core/file/FileManager.kt | 18 ++++++ .../core/file/FileOperationError.kt | 20 ++++++ .../core/file/command/CopyCommand.kt | 63 +++++++++++++++++++ .../core/file/command/FileCommand.kt | 14 +++++ .../core/file/FakeFileSystemManager.kt | 61 ++++++++++++++++++ .../core/file/command/CopyCommandTest.kt | 44 +++++++++++++ core/logging/impl-file/build.gradle.kts | 8 ++- .../core/logging/file/AndroidFileLogSink.kt | 44 +++++-------- .../core/logging/file/FileLogSink.android.kt | 11 +++- .../file/AndroidFileLogSinkTest.android.kt | 16 +++-- .../core/logging/file/FakeFileManager.kt | 35 +++++++++++ .../logging/file/FakeFileSystemManager.kt | 39 ------------ .../core/logging/file/FileLogSink.kt | 12 ++-- .../core/logging/file/FileLogSink.jvm.kt | 4 +- .../core/logging/file/JvmFileLogSink.kt | 3 +- gradle/libs.versions.toml | 2 + legacy/core/build.gradle.kts | 1 + .../java/com/fsck/k9/job/SyncDebugWorker.kt | 17 +++-- .../general/GeneralSettingsViewModel.kt | 3 +- 31 files changed, 370 insertions(+), 92 deletions(-) create mode 100644 core/file/src/commonMain/kotlin/net/thunderbird/core/file/DefaultFileManager.kt create mode 100644 core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileManager.kt create mode 100644 core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileOperationError.kt create mode 100644 core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt create mode 100644 core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/FileCommand.kt create mode 100644 core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt create mode 100644 core/file/src/commonTest/kotlin/net/thunderbird/core/file/command/CopyCommandTest.kt create mode 100644 core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileManager.kt delete mode 100644 core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt index 67f925ad839..cfe25354936 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt @@ -5,6 +5,8 @@ import net.thunderbird.app.common.core.configstore.appCommonCoreConfigStoreModul import net.thunderbird.app.common.core.logging.appCommonCoreLogger import net.thunderbird.app.common.core.ui.appCommonCoreUiModule import net.thunderbird.core.file.AndroidFileSystemManager +import net.thunderbird.core.file.DefaultFileManager +import net.thunderbird.core.file.FileManager import net.thunderbird.core.file.FileSystemManager import org.koin.core.module.Module import org.koin.dsl.module @@ -21,4 +23,10 @@ val appCommonCoreModule: Module = module { contentResolver = get().contentResolver, ) } + + single { + DefaultFileManager( + fileSystemManager = get(), + ) + } } diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt index 7e90d58c86c..3bb495a636c 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/LoggerModule.kt @@ -63,7 +63,7 @@ val appCommonCoreLogger = module { level = LogLevel.DEBUG, fileName = "thunderbird-sync-debug", fileLocation = get().filesDir.path, - fileSystemManager = get(), + fileManager = get(), ) } diff --git a/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt b/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt index 028f267a0f7..49c540cdd1d 100644 --- a/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt +++ b/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt @@ -172,6 +172,8 @@ co.touchlab:stately-concurrent-collections:2.1.0 co.touchlab:stately-strict-jvm:2.1.0 co.touchlab:stately-strict:2.1.0 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt b/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt index b3b70d7d4f2..66cbba9ad1f 100644 --- a/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt +++ b/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt @@ -174,6 +174,8 @@ co.touchlab:stately-strict:2.1.0 com.android.billingclient:billing-ktx:7.1.1 com.android.billingclient:billing:7.1.1 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt b/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt index 50d19946314..1e9e4892c55 100644 --- a/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt @@ -177,6 +177,8 @@ co.touchlab:stately-concurrent-collections:2.1.0 co.touchlab:stately-strict-jvm:2.1.0 co.touchlab:stately-strict:2.1.0 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt b/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt index 50d19946314..1e9e4892c55 100644 --- a/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt @@ -177,6 +177,8 @@ co.touchlab:stately-concurrent-collections:2.1.0 co.touchlab:stately-strict-jvm:2.1.0 co.touchlab:stately-strict:2.1.0 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt b/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt index 50d19946314..1e9e4892c55 100644 --- a/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt @@ -177,6 +177,8 @@ co.touchlab:stately-concurrent-collections:2.1.0 co.touchlab:stately-strict-jvm:2.1.0 co.touchlab:stately-strict:2.1.0 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt b/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt index 7acef923bf2..ddc53db4efd 100644 --- a/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt @@ -179,6 +179,8 @@ co.touchlab:stately-strict:2.1.0 com.android.billingclient:billing-ktx:7.1.1 com.android.billingclient:billing:7.1.1 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt b/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt index 7acef923bf2..ddc53db4efd 100644 --- a/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt @@ -179,6 +179,8 @@ co.touchlab:stately-strict:2.1.0 com.android.billingclient:billing-ktx:7.1.1 com.android.billingclient:billing:7.1.1 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt b/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt index 7acef923bf2..ddc53db4efd 100644 --- a/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt @@ -179,6 +179,8 @@ co.touchlab:stately-strict:2.1.0 com.android.billingclient:billing-ktx:7.1.1 com.android.billingclient:billing:7.1.1 com.beetstra.jutf7:jutf7:1.0.0 +com.eygraber:uri-kmp-android:0.0.21 +com.eygraber:uri-kmp:0.0.21 com.github.ByteHamster:SearchPreference:2.7.3 com.github.bumptech.glide:annotations:4.16.0 com.github.bumptech.glide:disklrucache:4.16.0 diff --git a/core/file/build.gradle.kts b/core/file/build.gradle.kts index b14699cc738..5f5cbd743dd 100644 --- a/core/file/build.gradle.kts +++ b/core/file/build.gradle.kts @@ -9,7 +9,13 @@ android { kotlin { sourceSets { commonMain.dependencies { + implementation(projects.core.outcome) + + implementation(libs.uri) implementation(libs.kotlinx.io.core) } + androidUnitTest.dependencies { + implementation(libs.robolectric) + } } } diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/DefaultFileManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/DefaultFileManager.kt new file mode 100644 index 00000000000..700371bb445 --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/DefaultFileManager.kt @@ -0,0 +1,15 @@ +package net.thunderbird.core.file + +import com.eygraber.uri.Uri +import net.thunderbird.core.file.command.CopyCommand +import net.thunderbird.core.outcome.Outcome + +/** + * Default implementation that delegates to internal commands. + */ +class DefaultFileManager( + private val fileSystemManager: FileSystemManager, +) : FileManager { + override suspend fun copy(sourceUri: Uri, destinationUri: Uri): Outcome = + CopyCommand(sourceUri, destinationUri).invoke(fileSystemManager) +} diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileManager.kt new file mode 100644 index 00000000000..4f214653ef6 --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileManager.kt @@ -0,0 +1,18 @@ +package net.thunderbird.core.file + +import com.eygraber.uri.Uri +import net.thunderbird.core.outcome.Outcome + +/** + * File manager for common file operations. + */ +interface FileManager { + /** + * Copy data from [sourceUri] to [destinationUri]. + * + * @param sourceUri The [Uri] of the source file. + * @param destinationUri The [Uri] of the destination file. + * @return [Outcome] with [Unit] on success or [FileOperationError] on failure. + */ + suspend fun copy(sourceUri: Uri, destinationUri: Uri): Outcome +} diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileOperationError.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileOperationError.kt new file mode 100644 index 00000000000..6b15604d36f --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileOperationError.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.file + +import com.eygraber.uri.Uri + +/** + * Common file operation errors. + */ +sealed interface FileOperationError { + /** Endpoint couldn't be opened or accessed. */ + data class Unavailable(val uri: Uri, val message: String? = null) : FileOperationError + + /** Failed while reading from the source. */ + data class ReadFailed(val uri: Uri, val message: String? = null) : FileOperationError + + /** Failed while writing to the destination. */ + data class WriteFailed(val uri: Uri, val message: String? = null) : FileOperationError + + /** Fallback when the error type can't be determined. */ + data class Unknown(val message: String? = null) : FileOperationError +} diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt new file mode 100644 index 00000000000..5a6d1cf280a --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt @@ -0,0 +1,63 @@ +package net.thunderbird.core.file.command + +import com.eygraber.uri.Uri +import kotlinx.io.Buffer +import net.thunderbird.core.file.FileOperationError +import net.thunderbird.core.file.FileSystemManager +import net.thunderbird.core.outcome.Outcome + +/** + * Copies data from [sourceUri] to [destinationUri] using buffered I/O. + */ +internal class CopyCommand( + private val sourceUri: Uri, + private val destinationUri: Uri, +) : FileCommand { + override suspend fun invoke(fs: FileSystemManager): Outcome { + // Open endpoints + val source = fs.openSource(sourceUri.toString()) + ?: return Outcome.Failure( + FileOperationError.Unavailable(sourceUri, "Unable to open source: $sourceUri"), + ) + val sink = fs.openSink(destinationUri.toString(), "wt") + ?: return Outcome.Failure( + FileOperationError.Unavailable(destinationUri, "Unable to open destination: $destinationUri"), + ) + + return try { + val buffer = Buffer() + while (true) { + val read = try { + source.readAtMostTo(buffer, BUFFER_SIZE) + } catch (t: Throwable) { + return Outcome.Failure(FileOperationError.ReadFailed(sourceUri, t.message), cause = t) + } + if (read <= 0L) break + try { + sink.write(buffer, read) + } catch (t: Throwable) { + return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, t.message), cause = t) + } + } + try { + sink.flush() + } catch (t: Throwable) { + return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, t.message), cause = t) + } + Outcome.Success(Unit) + } catch (t: Throwable) { + Outcome.Failure(FileOperationError.Unknown(t.message), cause = t) + } finally { + try { + source.close() + } catch (_: Throwable) {} + try { + sink.close() + } catch (_: Throwable) {} + } + } + + private companion object { + const val BUFFER_SIZE = 8_192L + } +} diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/FileCommand.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/FileCommand.kt new file mode 100644 index 00000000000..69f26cfb6f6 --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/FileCommand.kt @@ -0,0 +1,14 @@ +package net.thunderbird.core.file.command + +import net.thunderbird.core.file.FileOperationError +import net.thunderbird.core.file.FileSystemManager +import net.thunderbird.core.outcome.Outcome + +/** + * A command that performs a file operation using the provided [FileSystemManager]. + * + * @param T The type of the result produced by the command. + */ +internal fun interface FileCommand { + suspend operator fun invoke(fs: FileSystemManager): Outcome +} diff --git a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt new file mode 100644 index 00000000000..f9bbc0690f3 --- /dev/null +++ b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt @@ -0,0 +1,61 @@ +package net.thunderbird.core.file + +import kotlinx.io.Buffer +import kotlinx.io.RawSink +import kotlinx.io.RawSource + +/** + * In-memory fake implementation of FileSystemManager for common tests. + * Stores data in a simple map keyed by URI string. + */ +class FakeFileSystemManager : FileSystemManager { + + private val storage = mutableMapOf() + + override fun openSink(uriString: String, mode: String): RawSink? { + return object : RawSink { + private val collected = mutableListOf() + + override fun write(source: Buffer, byteCount: Long) { + // Read exactly byteCount bytes from source and collect + val count = byteCount.toInt() + repeat(count) { + if (source.size <= 0L) return + collected += source.readByte() + } + } + + override fun flush() { + storage[uriString] = collected.toByteArray() + } + + override fun close() { + // ensure data is stored + flush() + } + } + } + + override fun openSource(uriString: String): RawSource? { + val bytes = storage[uriString] ?: return null + return object : RawSource { + private val buffer = Buffer().apply { write(bytes) } + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + val toRead = minOf(byteCount, buffer.size) + if (toRead <= 0L) return 0L + sink.write(buffer, toRead) + return toRead + } + + override fun close() { + // no-op + } + } + } + + fun put(uriString: String, content: ByteArray) { + storage[uriString] = content + } + + fun get(uriString: String): ByteArray? = storage[uriString] +} diff --git a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/command/CopyCommandTest.kt b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/command/CopyCommandTest.kt new file mode 100644 index 00000000000..79b3e68594e --- /dev/null +++ b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/command/CopyCommandTest.kt @@ -0,0 +1,44 @@ +package net.thunderbird.core.file.command + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.eygraber.uri.toKmpUri +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.file.FakeFileSystemManager +import net.thunderbird.core.file.FileOperationError +import net.thunderbird.core.outcome.Outcome + +class CopyCommandTest { + + private val fs = FakeFileSystemManager() + private val testSubject = CopyCommand( + sourceUri = "mem://source".toKmpUri(), + destinationUri = "mem://dest".toKmpUri(), + ) + + @Test + fun `execute should copy bytes from source to destination`() = runTest { + // Arrange + val content = "Thunderbird common copy test".encodeToByteArray() + fs.put("mem://source", content) + + // Act + val result = testSubject(fs) + + // Assert + assertThat(result.isSuccess).isEqualTo(true) + assertThat(fs.get("mem://dest")?.decodeToString()).isEqualTo("Thunderbird common copy test") + } + + @Test + fun `execute should fail when source cannot be opened`() = runTest { + // Arrange - no source preloaded + + // Act + val result = testSubject(fs) + + // Assert + assertThat(result is Outcome.Failure).isEqualTo(true) + } +} diff --git a/core/logging/impl-file/build.gradle.kts b/core/logging/impl-file/build.gradle.kts index cecbc493bca..0a9c444bf11 100644 --- a/core/logging/impl-file/build.gradle.kts +++ b/core/logging/impl-file/build.gradle.kts @@ -9,9 +9,15 @@ android { kotlin { sourceSets { commonMain.dependencies { - implementation(libs.kotlinx.io.core) implementation(projects.core.logging.api) implementation(projects.core.file) + implementation(projects.core.outcome) + + implementation(libs.kotlinx.io.core) + implementation(libs.uri) + } + androidUnitTest.dependencies { + implementation(libs.robolectric) } } } diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt index 63d2faa0835..7e4bc0fe143 100644 --- a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt @@ -1,7 +1,9 @@ package net.thunderbird.core.logging.file +import androidx.core.net.toUri +import com.eygraber.uri.Uri +import com.eygraber.uri.toKmpUri import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream import kotlin.coroutines.CoroutineContext import kotlin.time.ExperimentalTime @@ -16,20 +18,19 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.io.Buffer -import kotlinx.io.RawSink import kotlinx.io.asSink -import net.thunderbird.core.file.FileSystemManager +import net.thunderbird.core.file.FileManager import net.thunderbird.core.logging.LogEvent import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.outcome.Outcome -private const val BUFFER_SIZE = 8192 // 8KB buffer size private const val LOG_BUFFER_COUNT = 4 open class AndroidFileLogSink( override val level: LogLevel, fileName: String, fileLocation: String, - private val fileSystemManager: FileSystemManager, + private val fileManager: FileManager, coroutineContext: CoroutineContext = Dispatchers.IO, ) : FileLogSink { @@ -94,14 +95,18 @@ open class AndroidFileLogSink( } } - override suspend fun export(uriString: String) { + override suspend fun export(uri: Uri) { if (accumulatedLogs.isNotEmpty()) { writeToLogFile() } - val sink = fileSystemManager.openSink(uriString, "wt") - ?: error("Error opening contentUri for writing") - copyInternalFileToExternal(sink) + val sourceUri = logFile.toUri().toKmpUri() + val result = fileManager.copy(sourceUri = sourceUri, destinationUri = uri) + if (result is Outcome.Failure) { + error( + "Error copying log to destination: ${result.error}", + ) + } // Clear the log file after export val outputStream = FileOutputStream(logFile) @@ -117,25 +122,4 @@ open class AndroidFileLogSink( outputStream.close() } } - - private fun copyInternalFileToExternal(sink: RawSink) { - val inputStream = FileInputStream(logFile) - - try { - val buffer = Buffer() - val byteArray = ByteArray(BUFFER_SIZE) - var bytesRead: Int - - while (inputStream.read(byteArray).also { bytesRead = it } != -1) { - buffer.write(byteArray, 0, bytesRead) - sink.write(buffer, buffer.size) - buffer.clear() - } - - sink.flush() - } finally { - inputStream.close() - sink.close() - } - } } diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt index 2f1d1508022..2726c5c715e 100644 --- a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt @@ -1,13 +1,18 @@ package net.thunderbird.core.logging.file -import net.thunderbird.core.file.FileSystemManager +import net.thunderbird.core.file.FileManager import net.thunderbird.core.logging.LogLevel actual fun FileLogSink( level: LogLevel, fileName: String, fileLocation: String, - fileSystemManager: FileSystemManager, + fileManager: FileManager, ): FileLogSink { - return AndroidFileLogSink(level, fileName, fileLocation, fileSystemManager) + return AndroidFileLogSink( + level = level, + fileName = fileName, + fileLocation = fileLocation, + fileManager = fileManager, + ) } diff --git a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt index 60674583cfe..9407c624ff6 100644 --- a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt +++ b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt @@ -3,6 +3,7 @@ package net.thunderbird.core.logging.file import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isNotNull +import com.eygraber.uri.toKmpUri import java.io.File import kotlin.test.Test import kotlin.time.ExperimentalTime @@ -18,8 +19,13 @@ import net.thunderbird.core.logging.LogLevel import org.junit.Before import org.junit.Rule import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) class AndroidFileLogSinkTest { @JvmField @@ -29,19 +35,19 @@ class AndroidFileLogSinkTest { private val initialTimestamp = 1234567890L private lateinit var logFile: File private lateinit var fileLocation: String - private lateinit var fileManager: FakeFileSystemManager + private lateinit var fileManager: FakeFileManager private lateinit var testSubject: AndroidFileLogSink @Before fun setUp() { fileLocation = folder.newFolder().absolutePath logFile = File(fileLocation, "test_log.txt") - fileManager = FakeFileSystemManager() + fileManager = FakeFileManager() testSubject = AndroidFileLogSink( level = LogLevel.INFO, fileName = "test_log", fileLocation = fileLocation, - fileSystemManager = fileManager, + fileManager = fileManager, coroutineContext = UnconfinedTestDispatcher(), ) } @@ -137,7 +143,7 @@ class AndroidFileLogSinkTest { runBlocking { // Act testSubject.flushAndCloseBuffer() - val exportUri = "content://test/export.txt" + val exportUri = "content://test/export.txt".toKmpUri() testSubject.export(exportUri) } @@ -182,7 +188,7 @@ class AndroidFileLogSinkTest { assertThat(logFile.readText()) .isEqualTo(logString1) runBlocking { - val exportUri = "content://test/export.txt" + val exportUri = "content://test/export.txt".toKmpUri() testSubject.export(exportUri) } diff --git a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileManager.kt b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileManager.kt new file mode 100644 index 00000000000..ccdd21359cb --- /dev/null +++ b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileManager.kt @@ -0,0 +1,35 @@ +package net.thunderbird.core.logging.file + +import com.eygraber.uri.Uri +import com.eygraber.uri.toAndroidUri +import java.io.File +import net.thunderbird.core.file.FileManager +import net.thunderbird.core.file.FileOperationError +import net.thunderbird.core.outcome.Outcome + +/** + * Fake FileManager that captures content copied from a local file source URI. + */ +class FakeFileManager : FileManager { + var exportedContent: String? = null + + override suspend fun copy( + sourceUri: Uri, + destinationUri: Uri, + ): Outcome { + return try { + val androidUri = sourceUri.toAndroidUri() + val content = when (androidUri.scheme) { + "file" -> { + val path = requireNotNull(androidUri.path) { "File URI without path: $androidUri" } + File(path).readText(Charsets.UTF_8) + } + else -> error("Unsupported scheme for FakeFileManager source: ${androidUri.scheme}") + } + exportedContent = content + Outcome.Success(Unit) + } catch (t: Throwable) { + Outcome.Failure(FileOperationError.Unknown(t.message), cause = t) + } + } +} diff --git a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt deleted file mode 100644 index 5238a7cd2aa..00000000000 --- a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -package net.thunderbird.core.logging.file - -import java.io.ByteArrayOutputStream -import java.nio.charset.StandardCharsets -import kotlinx.io.Buffer -import kotlinx.io.RawSink -import kotlinx.io.RawSource -import net.thunderbird.core.file.FileSystemManager - -class FakeFileSystemManager : FileSystemManager { - - var exportedContent: String? = null - private val outputStream = ByteArrayOutputStream() - - override fun openSink(uriString: String, mode: String): RawSink? { - return object : RawSink { - override fun write(source: Buffer, byteCount: Long) { - val bytes = ByteArray(byteCount.toInt()) - - for (i in 0 until byteCount.toInt()) { - bytes[i] = source.readByte() - } - - outputStream.write(bytes) - - exportedContent = String(outputStream.toByteArray(), StandardCharsets.UTF_8) - } - - override fun flush() = Unit - - override fun close() = Unit - } - } - - override fun openSource(uriString: String): RawSource? { - // Not needed for tests in this module - return null - } -} diff --git a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt index ffbb97ba73e..9fca3b9dbe7 100644 --- a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt +++ b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt @@ -1,16 +1,17 @@ package net.thunderbird.core.logging.file -import net.thunderbird.core.file.FileSystemManager +import com.eygraber.uri.Uri +import net.thunderbird.core.file.FileManager import net.thunderbird.core.logging.LogLevel import net.thunderbird.core.logging.LogSink interface FileLogSink : LogSink { /** * Exports from the logging method to the requested external file - * @param uriString The [String] for the URI to export the log to + * @param uri The [String] for the URI to export the log to * **/ - suspend fun export(uriString: String) + suspend fun export(uri: Uri) /** * On a crash or close, flushes buffer to file fo avoid log loss @@ -27,11 +28,12 @@ interface FileLogSink : LogSink { * @param level The minimum [LogLevel] for messages to be logged. * @param fileName The [String] fileName to log to * @param fileLocation The [String] fileLocation for the log file - * @param fileSystemManager The [FileSystemManager] abstraction for opening the file stream + * @param fileManager The [FileManager] to handle file operations + * @return A [FileLogSink] instance for logging to a file. */ expect fun FileLogSink( level: LogLevel, fileName: String, fileLocation: String, - fileSystemManager: FileSystemManager, + fileManager: FileManager, ): FileLogSink diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt index 89a97092832..ae960a83959 100644 --- a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt +++ b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt @@ -1,13 +1,13 @@ package net.thunderbird.core.logging.file -import net.thunderbird.core.file.FileSystemManager +import net.thunderbird.core.file.FileManager import net.thunderbird.core.logging.LogLevel actual fun FileLogSink( level: LogLevel, fileName: String, fileLocation: String, - fileSystemManager: FileSystemManager, + fileManager: FileManager, ): FileLogSink { return JvmFileLogSink(level, fileName, fileLocation) } diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt index 49f27e7ce5f..2114fb51a81 100644 --- a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt +++ b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt @@ -1,5 +1,6 @@ package net.thunderbird.core.logging.file +import com.eygraber.uri.Uri import net.thunderbird.core.logging.LogEvent import net.thunderbird.core.logging.LogLevel @@ -14,7 +15,7 @@ internal class JvmFileLogSink( event.throwable?.printStackTrace() } - override suspend fun export(uriString: String) { + override suspend fun export(uri: Uri) { // TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9435 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f985b60f474..cd81b7b9bb3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -108,6 +108,7 @@ searchPreference = "2.7.3" spotlessPlugin = "8.0.0" timber = "5.0.1" turbine = "1.2.1" +uri = "0.0.21" xmlpull = "1.0" zxing = "3.5.3" @@ -279,6 +280,7 @@ safeContentResolver = { module = "de.cketti.safecontentresolver:safe-content-res searchPreference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchPreference" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +uri = { module = "com.eygraber:uri-kmp", version.ref = "uri" } xmlpull = { module = "com.github.cketti:xmlpull-extracted-from-android", version.ref = "xmlpull" } zxing = { module = "com.google.zxing:core", version.ref = "zxing" } diff --git a/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index 757f4c57a91..1cf0d9e981e 100644 --- a/legacy/core/build.gradle.kts +++ b/legacy/core/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(libs.timber) implementation(libs.mime4j.core) implementation(libs.mime4j.dom) + implementation(libs.uri) implementation(projects.feature.navigation.drawer.api) testApi(projects.core.testing) diff --git a/legacy/core/src/main/java/com/fsck/k9/job/SyncDebugWorker.kt b/legacy/core/src/main/java/com/fsck/k9/job/SyncDebugWorker.kt index a1557d99778..c1c2bde3fa9 100644 --- a/legacy/core/src/main/java/com/fsck/k9/job/SyncDebugWorker.kt +++ b/legacy/core/src/main/java/com/fsck/k9/job/SyncDebugWorker.kt @@ -3,6 +3,7 @@ package com.fsck.k9.job import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.eygraber.uri.toKmpUri import java.io.IOException import net.thunderbird.core.logging.Logger import net.thunderbird.core.logging.composite.CompositeLogSink @@ -20,16 +21,24 @@ class SyncDebugWorker( ) : CoroutineWorker(context, parameters) { override suspend fun doWork(): Result { - try { - fileLogSink.export(inputData.getString("exportUriString").toString()) + val result = try { + val uriString = inputData.getString("exportUriString") + if (uriString == null) { + Result.failure() + } else { + fileLogSink.export(uriString.toKmpUri()) + Result.success() + } } catch (e: IOException) { baseLogger.error(message = { "Failed to export log" }, throwable = e) - return Result.failure() + Result.failure() } + syncDebugCompositeSink.manager.remove(fileLogSink) generalSettingsManager.update { settings -> settings.copy(debugging = settings.debugging.copy(isSyncLoggingEnabled = false)) } - return Result.success() + + return result } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt index 1a886e0f2b3..264e7ef848c 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.k9mail.feature.launcher.FeatureLauncherActivity import app.k9mail.feature.launcher.FeatureLauncherTarget +import com.eygraber.uri.toKmpUri import com.fsck.k9.ui.BuildConfig import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -41,7 +42,7 @@ class GeneralSettingsViewModel( viewModelScope.launch { setExportingState() try { - syncDebugFileLogSink.export(contentUri) + syncDebugFileLogSink.export(contentUri.toKmpUri()) showSnackbar(GeneralSettingsUiState.Success) } catch (e: Exception) { Log.e(e, "Failed to write log to URI") From e6a285350c84e81df341b3bab5f70060d7d1db33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Mon, 20 Oct 2025 11:36:23 +0200 Subject: [PATCH 3/6] refactor: remove mode from api --- .../net/thunderbird/core/file/AndroidFileSystemManager.kt | 5 +++-- .../thunderbird/core/file/AndroidFileSystemManagerTest.kt | 2 +- .../kotlin/net/thunderbird/core/file/FileSystemManager.kt | 5 +++-- .../kotlin/net/thunderbird/core/file/command/CopyCommand.kt | 2 +- .../net/thunderbird/core/file/FakeFileSystemManager.kt | 2 +- .../kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt | 4 ++-- .../net/thunderbird/core/file/JvmFileSystemManagerTest.kt | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt index 7473969fa0b..26482ffbe9c 100644 --- a/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt +++ b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt @@ -14,9 +14,10 @@ import kotlinx.io.asSource class AndroidFileSystemManager( private val contentResolver: ContentResolver, ) : FileSystemManager { - override fun openSink(uriString: String, mode: String): RawSink? { + override fun openSink(uriString: String): RawSink? { val uri: Uri = uriString.toUri() - return contentResolver.openOutputStream(uri, mode)?.asSink() + // Use truncate/overwrite mode by default + return contentResolver.openOutputStream(uri, "wt")?.asSink() } override fun openSource(uriString: String): RawSource? { diff --git a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt index 1e750979bb3..f9a2c5f5f23 100644 --- a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt +++ b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt @@ -33,7 +33,7 @@ class AndroidFileSystemManagerTest { val testText = "Hello Thunderbird Android!" // Act - val sink = checkNotNull(testSubject.openSink(uri.toString(), "wt")) + val sink = checkNotNull(testSubject.openSink(uri.toString())) val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } sink.write(writeBuffer, writeBuffer.size) sink.flush() diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt index 82c03bb1cae..b7e4a809a33 100644 --- a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt @@ -10,11 +10,12 @@ interface FileSystemManager { /** * Opens a sink for writing to a URI. * + * Implementations should open the destination for writing in overwrite/truncate mode. + * * @param uriString The URI string to open a sink for - * @param mode The mode to open the sink in (e.g., "wt" for write text) * @return A sink for writing to the URI, or null if the URI couldn't be opened */ - fun openSink(uriString: String, mode: String): RawSink? + fun openSink(uriString: String): RawSink? /** * Opens a source for reading from a URI. diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt index 5a6d1cf280a..289b088bfda 100644 --- a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt @@ -19,7 +19,7 @@ internal class CopyCommand( ?: return Outcome.Failure( FileOperationError.Unavailable(sourceUri, "Unable to open source: $sourceUri"), ) - val sink = fs.openSink(destinationUri.toString(), "wt") + val sink = fs.openSink(destinationUri.toString()) ?: return Outcome.Failure( FileOperationError.Unavailable(destinationUri, "Unable to open destination: $destinationUri"), ) diff --git a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt index f9bbc0690f3..038b5e98da2 100644 --- a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt +++ b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt @@ -12,7 +12,7 @@ class FakeFileSystemManager : FileSystemManager { private val storage = mutableMapOf() - override fun openSink(uriString: String, mode: String): RawSink? { + override fun openSink(uriString: String): RawSink? { return object : RawSink { private val collected = mutableListOf() diff --git a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt index 4d9766aac0a..90dea8e48db 100644 --- a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt +++ b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt @@ -12,13 +12,13 @@ import kotlinx.io.asSource * JVM implementation of [FileSystemManager] using java.io streams. */ class JvmFileSystemManager : FileSystemManager { - override fun openSink(uriString: String, mode: String): RawSink? { + override fun openSink(uriString: String): RawSink? { // Only support simple file paths for JVM implementation return try { val file = File(uriString) // create parent directories if necessary file.parentFile?.mkdirs() - val append = mode.contains("a") // crude check for append mode + val append = false // overwrite/truncate by default FileOutputStream(file, append).asSink() } catch (_: Throwable) { null diff --git a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt index a95daba68bd..ae9ec12648d 100644 --- a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt +++ b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt @@ -21,7 +21,7 @@ class JvmFileSystemManagerTest { // Arrange val tempFile: File = folder.newFile("tb-file-fs-test.txt") val testText = "Hello Thunderbird!" - val sink = checkNotNull(testSubject.openSink(tempFile.absolutePath, "wt")) + val sink = checkNotNull(testSubject.openSink(tempFile.absolutePath)) // Act val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } From 270436b8e732d5da380c517e0feee54dac0e2877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Mon, 20 Oct 2025 13:22:26 +0200 Subject: [PATCH 4/6] feat(core-file): ensure Uri is used for the FileSystemManager --- core/file/build.gradle.kts | 3 ++- .../core/file/AndroidFileSystemManager.kt | 14 +++++------ .../core/file/AndroidFileSystemManagerTest.kt | 5 ++-- .../core/file/FileSystemManager.kt | 9 +++---- .../core/file/command/CopyCommand.kt | 24 +++++++++---------- .../core/file/FakeFileSystemManager.kt | 11 +++++---- .../core/file/JvmFileSystemManager.kt | 10 ++++---- .../core/file/JvmFileSystemManagerTest.kt | 6 +++-- legacy/ui/legacy/build.gradle.kts | 1 + 9 files changed, 46 insertions(+), 37 deletions(-) diff --git a/core/file/build.gradle.kts b/core/file/build.gradle.kts index 5f5cbd743dd..ee369256b9f 100644 --- a/core/file/build.gradle.kts +++ b/core/file/build.gradle.kts @@ -9,9 +9,10 @@ android { kotlin { sourceSets { commonMain.dependencies { + api(libs.uri) + implementation(projects.core.outcome) - implementation(libs.uri) implementation(libs.kotlinx.io.core) } androidUnitTest.dependencies { diff --git a/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt index 26482ffbe9c..ae16a0eaf80 100644 --- a/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt +++ b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt @@ -1,8 +1,8 @@ package net.thunderbird.core.file import android.content.ContentResolver -import android.net.Uri -import androidx.core.net.toUri +import com.eygraber.uri.Uri +import com.eygraber.uri.toAndroidUri import kotlinx.io.RawSink import kotlinx.io.RawSource import kotlinx.io.asSink @@ -14,14 +14,12 @@ import kotlinx.io.asSource class AndroidFileSystemManager( private val contentResolver: ContentResolver, ) : FileSystemManager { - override fun openSink(uriString: String): RawSink? { - val uri: Uri = uriString.toUri() + override fun openSink(uri: Uri): RawSink? { // Use truncate/overwrite mode by default - return contentResolver.openOutputStream(uri, "wt")?.asSink() + return contentResolver.openOutputStream(uri.toAndroidUri(), "wt")?.asSink() } - override fun openSource(uriString: String): RawSource? { - val uri: Uri = uriString.toUri() - return contentResolver.openInputStream(uri)?.asSource() + override fun openSource(uri: Uri): RawSource? { + return contentResolver.openInputStream(uri.toAndroidUri())?.asSource() } } diff --git a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt index f9a2c5f5f23..4af299697a2 100644 --- a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt +++ b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import assertk.assertThat import assertk.assertions.isEqualTo +import com.eygraber.uri.toKmpUri import kotlinx.io.Buffer import org.junit.Rule import org.junit.Test @@ -33,13 +34,13 @@ class AndroidFileSystemManagerTest { val testText = "Hello Thunderbird Android!" // Act - val sink = checkNotNull(testSubject.openSink(uri.toString())) + val sink = checkNotNull(testSubject.openSink(uri.toKmpUri())) val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } sink.write(writeBuffer, writeBuffer.size) sink.flush() sink.close() - val source = checkNotNull(testSubject.openSource(uri.toString())) + val source = checkNotNull(testSubject.openSource(uri.toKmpUri())) val readBuffer = Buffer() source.readAtMostTo(readBuffer, 1024) val bytes = ByteArray(readBuffer.size.toInt()) diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt index b7e4a809a33..6db8069a3bf 100644 --- a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt @@ -1,5 +1,6 @@ package net.thunderbird.core.file +import com.eygraber.uri.Uri import kotlinx.io.RawSink import kotlinx.io.RawSource @@ -12,16 +13,16 @@ interface FileSystemManager { * * Implementations should open the destination for writing in overwrite/truncate mode. * - * @param uriString The URI string to open a sink for + * @param uri The URI to open a sink for * @return A sink for writing to the URI, or null if the URI couldn't be opened */ - fun openSink(uriString: String): RawSink? + fun openSink(uri: Uri): RawSink? /** * Opens a source for reading from a URI. * - * @param uriString The URI string to open a source for + * @param uri The URI to open a source for * @return A source for reading from the URI, or null if the URI couldn't be opened */ - fun openSource(uriString: String): RawSource? + fun openSource(uri: Uri): RawSource? } diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt index 289b088bfda..ff28c901ed8 100644 --- a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt @@ -15,11 +15,11 @@ internal class CopyCommand( ) : FileCommand { override suspend fun invoke(fs: FileSystemManager): Outcome { // Open endpoints - val source = fs.openSource(sourceUri.toString()) + val source = fs.openSource(sourceUri) ?: return Outcome.Failure( FileOperationError.Unavailable(sourceUri, "Unable to open source: $sourceUri"), ) - val sink = fs.openSink(destinationUri.toString()) + val sink = fs.openSink(destinationUri) ?: return Outcome.Failure( FileOperationError.Unavailable(destinationUri, "Unable to open destination: $destinationUri"), ) @@ -29,31 +29,31 @@ internal class CopyCommand( while (true) { val read = try { source.readAtMostTo(buffer, BUFFER_SIZE) - } catch (t: Throwable) { - return Outcome.Failure(FileOperationError.ReadFailed(sourceUri, t.message), cause = t) + } catch (e: Exception) { + return Outcome.Failure(FileOperationError.ReadFailed(sourceUri, e.message), cause = e) } if (read <= 0L) break try { sink.write(buffer, read) - } catch (t: Throwable) { - return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, t.message), cause = t) + } catch (e: Exception) { + return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, e.message), cause = e) } } try { sink.flush() - } catch (t: Throwable) { - return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, t.message), cause = t) + } catch (e: Exception) { + return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, e.message), cause = e) } Outcome.Success(Unit) - } catch (t: Throwable) { - Outcome.Failure(FileOperationError.Unknown(t.message), cause = t) + } catch (e: Exception) { + Outcome.Failure(FileOperationError.Unknown(e.message), cause = e) } finally { try { source.close() - } catch (_: Throwable) {} + } catch (_: Exception) {} try { sink.close() - } catch (_: Throwable) {} + } catch (_: Exception) {} } } diff --git a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt index 038b5e98da2..b8054aab03d 100644 --- a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt +++ b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt @@ -1,5 +1,6 @@ package net.thunderbird.core.file +import com.eygraber.uri.Uri import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.io.RawSource @@ -12,7 +13,8 @@ class FakeFileSystemManager : FileSystemManager { private val storage = mutableMapOf() - override fun openSink(uriString: String): RawSink? { + override fun openSink(uri: Uri): RawSink? { + val key = uri.toString() return object : RawSink { private val collected = mutableListOf() @@ -26,7 +28,7 @@ class FakeFileSystemManager : FileSystemManager { } override fun flush() { - storage[uriString] = collected.toByteArray() + storage[key] = collected.toByteArray() } override fun close() { @@ -36,8 +38,9 @@ class FakeFileSystemManager : FileSystemManager { } } - override fun openSource(uriString: String): RawSource? { - val bytes = storage[uriString] ?: return null + override fun openSource(uri: Uri): RawSource? { + val key = uri.toString() + val bytes = storage[key] ?: return null return object : RawSource { private val buffer = Buffer().apply { write(bytes) } override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { diff --git a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt index 90dea8e48db..cdea3c268d9 100644 --- a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt +++ b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt @@ -1,5 +1,7 @@ package net.thunderbird.core.file +import com.eygraber.uri.Uri +import com.eygraber.uri.toURI import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -12,10 +14,10 @@ import kotlinx.io.asSource * JVM implementation of [FileSystemManager] using java.io streams. */ class JvmFileSystemManager : FileSystemManager { - override fun openSink(uriString: String): RawSink? { + override fun openSink(uri: Uri): RawSink? { // Only support simple file paths for JVM implementation return try { - val file = File(uriString) + val file = File(uri.toURI()) // create parent directories if necessary file.parentFile?.mkdirs() val append = false // overwrite/truncate by default @@ -25,9 +27,9 @@ class JvmFileSystemManager : FileSystemManager { } } - override fun openSource(uriString: String): RawSource? { + override fun openSource(uri: Uri): RawSource? { return try { - val file = File(uriString) + val file = File(uri.toURI()) FileInputStream(file).asSource() } catch (_: Throwable) { null diff --git a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt index ae9ec12648d..e274fc504c7 100644 --- a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt +++ b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt @@ -2,6 +2,7 @@ package net.thunderbird.core.file import assertk.assertThat import assertk.assertions.isEqualTo +import com.eygraber.uri.Uri import java.io.File import kotlinx.io.Buffer import org.junit.Rule @@ -21,7 +22,8 @@ class JvmFileSystemManagerTest { // Arrange val tempFile: File = folder.newFile("tb-file-fs-test.txt") val testText = "Hello Thunderbird!" - val sink = checkNotNull(testSubject.openSink(tempFile.absolutePath)) + val uri = Uri.parse(tempFile.toURI().toString()) + val sink = checkNotNull(testSubject.openSink(uri)) // Act val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } @@ -29,7 +31,7 @@ class JvmFileSystemManagerTest { sink.flush() sink.close() - val source = checkNotNull(testSubject.openSource(tempFile.absolutePath)) + val source = checkNotNull(testSubject.openSource(uri)) val readBuffer = Buffer() source.readAtMostTo(readBuffer, 1024) val bytes = ByteArray(readBuffer.size.toInt()) diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index fb9dafe5b4b..6cfe42b451c 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.mime4j.core) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) + implementation(libs.uri) implementation(libs.glide) annotationProcessor(libs.glide.compiler) From 85a9fd5bb71be72f2faa9808a698dd8dfb2d5981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Mon, 20 Oct 2025 13:39:21 +0200 Subject: [PATCH 5/6] feat(core-file): add WriteMode to support append vs truncate --- .../core/file/AndroidFileSystemManager.kt | 10 ++- .../core/file/AndroidFileSystemManagerTest.kt | 78 +++++++++++++++++++ .../core/file/FileSystemManager.kt | 7 +- .../net/thunderbird/core/file/WriteMode.kt | 12 +++ .../core/file/command/CopyCommand.kt | 3 +- .../core/file/FakeFileSystemManager.kt | 8 +- .../core/file/JvmFileSystemManager.kt | 7 +- .../core/file/JvmFileSystemManagerTest.kt | 78 +++++++++++++++++++ 8 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 core/file/src/commonMain/kotlin/net/thunderbird/core/file/WriteMode.kt diff --git a/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt index ae16a0eaf80..613d1820e49 100644 --- a/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt +++ b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt @@ -14,9 +14,13 @@ import kotlinx.io.asSource class AndroidFileSystemManager( private val contentResolver: ContentResolver, ) : FileSystemManager { - override fun openSink(uri: Uri): RawSink? { - // Use truncate/overwrite mode by default - return contentResolver.openOutputStream(uri.toAndroidUri(), "wt")?.asSink() + override fun openSink(uri: Uri, mode: WriteMode): RawSink? { + // Map WriteMode to ContentResolver open modes: "wt" (truncate) or "wa" (append) + val androidMode = when (mode) { + WriteMode.Truncate -> "wt" + WriteMode.Append -> "wa" + } + return contentResolver.openOutputStream(uri.toAndroidUri(), androidMode)?.asSink() } override fun openSource(uri: Uri): RawSource? { diff --git a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt index 4af299697a2..60113b0eda7 100644 --- a/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt +++ b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt @@ -53,4 +53,82 @@ class AndroidFileSystemManagerTest { // Assert assertThat(result).isEqualTo(testText) } + + @Test + fun openSink_withAppend_shouldAppendToExistingContent() { + // Arrange + val tempFile = folder.newFile("tb-file-fs-test-android-append.txt") + val uri: Uri = Uri.fromFile(tempFile) + val initial = "Hello" + val extra = " Android" + + // Write initial content (truncate by default) + run { + val sink = checkNotNull(testSubject.openSink(uri.toKmpUri())) + val buf = Buffer().apply { write(initial.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Append extra content + run { + val sink = checkNotNull(testSubject.openSink(uri.toKmpUri(), WriteMode.Append)) + val buf = Buffer().apply { write(extra.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Read back + val source = checkNotNull(testSubject.openSource(uri.toKmpUri())) + val readBuffer = Buffer() + source.readAtMostTo(readBuffer, 1024) + val bytes = ByteArray(readBuffer.size.toInt()) + repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() } + val result = bytes.decodeToString() + source.close() + + // Assert + assertThat(result).isEqualTo(initial + extra) + } + + @Test + fun openSink_withTruncate_shouldOverwriteExistingContent() { + // Arrange + val tempFile = folder.newFile("tb-file-fs-test-android-truncate.txt") + val uri: Uri = Uri.fromFile(tempFile) + val first = "First" + val second = "Second" + + // Write first content + run { + val sink = checkNotNull(testSubject.openSink(uri.toKmpUri(), WriteMode.Truncate)) + val buf = Buffer().apply { write(first.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Overwrite with second content + run { + val sink = checkNotNull(testSubject.openSink(uri.toKmpUri(), WriteMode.Truncate)) + val buf = Buffer().apply { write(second.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Read back + val source = checkNotNull(testSubject.openSource(uri.toKmpUri())) + val readBuffer = Buffer() + source.readAtMostTo(readBuffer, 1024) + val bytes = ByteArray(readBuffer.size.toInt()) + repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() } + val result = bytes.decodeToString() + source.close() + + // Assert + assertThat(result).isEqualTo(second) + } } diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt index 6db8069a3bf..f37f0fa3b59 100644 --- a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt @@ -11,12 +11,15 @@ interface FileSystemManager { /** * Opens a sink for writing to a URI. * - * Implementations should open the destination for writing in overwrite/truncate mode. + * Implementations must honor the requested [mode]: + * - [WriteMode.Truncate]: overwrite existing content (truncate) or create if missing + * - [WriteMode.Append]: append to existing content or create if missing * * @param uri The URI to open a sink for + * @param mode The write mode (truncate/append), defaults to [WriteMode.Truncate] * @return A sink for writing to the URI, or null if the URI couldn't be opened */ - fun openSink(uri: Uri): RawSink? + fun openSink(uri: Uri, mode: WriteMode = WriteMode.Truncate): RawSink? /** * Opens a source for reading from a URI. diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/WriteMode.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/WriteMode.kt new file mode 100644 index 00000000000..1d6f0a7f689 --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/WriteMode.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.file + +/** + * Indicates how a sink should be opened for writing. + * + * - [Truncate]: Overwrite existing content or create if missing + * - [Append]: Append to existing content or create if missing + */ +enum class WriteMode { + Truncate, + Append, +} diff --git a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt index ff28c901ed8..bdfd624cf18 100644 --- a/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt @@ -4,6 +4,7 @@ import com.eygraber.uri.Uri import kotlinx.io.Buffer import net.thunderbird.core.file.FileOperationError import net.thunderbird.core.file.FileSystemManager +import net.thunderbird.core.file.WriteMode import net.thunderbird.core.outcome.Outcome /** @@ -19,7 +20,7 @@ internal class CopyCommand( ?: return Outcome.Failure( FileOperationError.Unavailable(sourceUri, "Unable to open source: $sourceUri"), ) - val sink = fs.openSink(destinationUri) + val sink = fs.openSink(destinationUri, WriteMode.Truncate) ?: return Outcome.Failure( FileOperationError.Unavailable(destinationUri, "Unable to open destination: $destinationUri"), ) diff --git a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt index b8054aab03d..ff61e3f50bb 100644 --- a/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt +++ b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt @@ -13,10 +13,14 @@ class FakeFileSystemManager : FileSystemManager { private val storage = mutableMapOf() - override fun openSink(uri: Uri): RawSink? { + override fun openSink(uri: Uri, mode: WriteMode): RawSink? { val key = uri.toString() return object : RawSink { - private val collected = mutableListOf() + private val collected = mutableListOf().apply { + if (mode == WriteMode.Append) { + storage[key]?.forEach { add(it) } + } + } override fun write(source: Buffer, byteCount: Long) { // Read exactly byteCount bytes from source and collect diff --git a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt index cdea3c268d9..e5e2e3ce58f 100644 --- a/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt +++ b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt @@ -14,13 +14,16 @@ import kotlinx.io.asSource * JVM implementation of [FileSystemManager] using java.io streams. */ class JvmFileSystemManager : FileSystemManager { - override fun openSink(uri: Uri): RawSink? { + override fun openSink(uri: Uri, mode: WriteMode): RawSink? { // Only support simple file paths for JVM implementation return try { val file = File(uri.toURI()) // create parent directories if necessary file.parentFile?.mkdirs() - val append = false // overwrite/truncate by default + val append = when (mode) { + WriteMode.Truncate -> false + WriteMode.Append -> true + } FileOutputStream(file, append).asSink() } catch (_: Throwable) { null diff --git a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt index e274fc504c7..596272f21fd 100644 --- a/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt +++ b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt @@ -44,4 +44,82 @@ class JvmFileSystemManagerTest { // Assert assertThat(result).isEqualTo(testText) } + + @Test + fun `openSink with Append should append to existing content`() { + // Arrange + val tempFile: File = folder.newFile("tb-file-fs-append.txt") + val uri = Uri.parse(tempFile.toURI().toString()) + val initial = "Hello" + val extra = " World" + + // Write initial content (truncate by default) + run { + val sink = checkNotNull(testSubject.openSink(uri)) + val buf = Buffer().apply { write(initial.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Append extra content + run { + val sink = checkNotNull(testSubject.openSink(uri, WriteMode.Append)) + val buf = Buffer().apply { write(extra.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Read back + val source = checkNotNull(testSubject.openSource(uri)) + val readBuffer = Buffer() + source.readAtMostTo(readBuffer, 1024) + val bytes = ByteArray(readBuffer.size.toInt()) + repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() } + val result = bytes.decodeToString() + source.close() + + // Assert + assertThat(result).isEqualTo(initial + extra) + } + + @Test + fun `openSink with Truncate should overwrite existing content`() { + // Arrange + val tempFile: File = folder.newFile("tb-file-fs-truncate.txt") + val uri = Uri.parse(tempFile.toURI().toString()) + val first = "First" + val second = "Second" + + // Write first content + run { + val sink = checkNotNull(testSubject.openSink(uri, WriteMode.Truncate)) + val buf = Buffer().apply { write(first.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Overwrite with second content + run { + val sink = checkNotNull(testSubject.openSink(uri, WriteMode.Truncate)) + val buf = Buffer().apply { write(second.encodeToByteArray()) } + sink.write(buf, buf.size) + sink.flush() + sink.close() + } + + // Read back + val source = checkNotNull(testSubject.openSource(uri)) + val readBuffer = Buffer() + source.readAtMostTo(readBuffer, 1024) + val bytes = ByteArray(readBuffer.size.toInt()) + repeat(bytes.size) { i -> bytes[i] = readBuffer.readByte() } + val result = bytes.decodeToString() + source.close() + + // Assert + assertThat(result).isEqualTo(second) + } } From 64ca95c4f8d4d5de78bc671db6343cd141264209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Mon, 20 Oct 2025 13:30:36 +0200 Subject: [PATCH 6/6] docs: add readme to explain the core:file module --- core/file/README.md | 120 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 core/file/README.md diff --git a/core/file/README.md b/core/file/README.md new file mode 100644 index 00000000000..a670d70c1f8 --- /dev/null +++ b/core/file/README.md @@ -0,0 +1,120 @@ +# Thunderbird Core File Module + +This module provides a simple, consistent API for common file operations across Android and JVM platforms. + +## Architecture + +The file system layer is split into two levels: + +- Public low-level I/O: `FileSystemManager` opens `RawSource`/`RawSink` for a given `Uri`. + - Android actual: `AndroidFileSystemManager` + - JVM actual: `JvmFileSystemManager` +- Public high-level facade: `FileManager` for common operations (currently: copy). + - Default implementation: `DefaultFileManager` delegating to internal commands +- Internal commands: e.g., `CopyCommand(source, dest)` implement operations using `FileSystemManager`. + - Hidden from public API; return `Outcome` internally to preserve error context. +- `RawSource`/`RawSink` come from `kotlinx-io` and are referenced in the public API. + +### Core Components + +```mermaid +classDiagram + class FileManager { + +copy(source: Uri, dest: Uri): Outcome + } + + class DefaultFileManager { + -fs: FileSystemManager + } + + class FileSystemManager { + +openSource(uri: Uri): RawSource? + +openSink(uri: Uri, mode: WriteMode = WriteMode.Truncate): RawSink? + } + + class CopyCommand { + -source: Uri + -destination: Uri + +invoke(fs: FileSystemManager): Outcome + } + + class FileOperationError { + } + + DefaultFileManager ..> FileSystemManager + CopyCommand --> FileSystemManager + DefaultFileManager ..> CopyCommand : delegates +``` + +## Getting Started + +### Dependency setup + +Add the module to your Gradle build. Then, depending on your platform, provide an actual `FileSystemManager` and wire a `FileManager`: + +```kotlin +// Koin example (Android) +single { AndroidFileSystemManager(androidContext().contentResolver) } +single { DefaultFileManager(get()) } +``` + +For JVM-only tools/tests: + +```kotlin +val fs: FileSystemManager = JvmFileSystemManager() +val fileManager: FileManager = DefaultFileManager(fs) +``` + +## Public API + +- FileManager + - `suspend fun copy(sourceUri: Uri, destinationUri: Uri): Outcome` +- FileSystemManager + - `fun openSource(uri: Uri): RawSource?` + - `fun openSink(uri: Uri, mode: WriteMode = WriteMode.Truncate): RawSink?` + - Behavior: + - Sinks default to overwrite/truncate. Pass `WriteMode.Append` to append where supported. + - Returns null when the URI cannot be opened (e.g., missing permissions, unsupported scheme). + - Thread-safety: Implementations are stateless and safe to use from multiple threads, but the returned streams must be used/closed by the caller. +- `enum class WriteMode { Truncate, Append }` + +## URI type + +The API uses a KMP‑friendly `Uri` type (com.eygraber.uri.Uri). On Android, convert a platform URI using the provided extension: + +```kotlin +val kmpUri = androidUri.toKmpUri() +``` + +To build URIs in tests or common code, you can parse a string: + +```kotlin +val source = "file:///path/to/file.txt".toKmpUri() +``` + +## Supported URIs (by platform) + +- Android (AndroidFileSystemManager): + - `content://` via `ContentResolver` + - `file://` via `ContentResolver` +- JVM (JvmFileSystemManager): + - `file://` URIs only (non-`file:` schemes are not supported and will return null). +- iOS: No actual yet in this repository, but the API is compatible. An iOS actual can use `NSFileManager`/`NSURL`. + +## Error handling best practices + +- `openSource(uri)`/`openSink(uri)` return null on failure. Always check for null and handle gracefully (e.g., show a message, request permissions). +- On Android, failures are frequently due to missing URI permissions; prefer SAF pickers and persist permissions when needed. + +## Performance and buffering + +- Internal copy uses a buffered loop (`BUFFER_SIZE = 8_192L`). +- Streams are flushed and closed to avoid leaks. +- Public `openSource`/`openSink` are not suspending; perform I/O on an appropriate dispatcher/thread when needed. + +## Limitations and notes + +- Android: Ensure the app holds read/write permissions for the target URI (e.g., via SAF and optionally `takePersistableUriPermission`). +- JVM: Only `file:` URIs are supported by `JvmFileSystemManager`. +- iOS: No actual yet. The public API is prepared for an iOS actual using `NSFileManager`/`NSURL`. +