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..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 @@ -1,8 +1,13 @@ 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.DefaultFileManager +import net.thunderbird.core.file.FileManager +import net.thunderbird.core.file.FileSystemManager import org.koin.core.module.Module import org.koin.dsl.module @@ -12,4 +17,16 @@ val appCommonCoreModule: Module = module { appCommonCoreLogger, appCommonCoreUiModule, ) + + single { + AndroidFileSystemManager( + 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 2dfe2abe1c8..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 @@ -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), + 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/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`. + diff --git a/core/file/build.gradle.kts b/core/file/build.gradle.kts new file mode 100644 index 00000000000..ee369256b9f --- /dev/null +++ b/core/file/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.file" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(libs.uri) + + implementation(projects.core.outcome) + + implementation(libs.kotlinx.io.core) + } + androidUnitTest.dependencies { + implementation(libs.robolectric) + } + } +} 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 new file mode 100644 index 00000000000..613d1820e49 --- /dev/null +++ b/core/file/src/androidMain/kotlin/net/thunderbird/core/file/AndroidFileSystemManager.kt @@ -0,0 +1,29 @@ +package net.thunderbird.core.file + +import android.content.ContentResolver +import com.eygraber.uri.Uri +import com.eygraber.uri.toAndroidUri +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. + */ +class AndroidFileSystemManager( + private val contentResolver: ContentResolver, +) : FileSystemManager { + 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? { + 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 new file mode 100644 index 00000000000..60113b0eda7 --- /dev/null +++ b/core/file/src/androidUnitTest/kotlin/net/thunderbird/core/file/AndroidFileSystemManagerTest.kt @@ -0,0 +1,134 @@ +package net.thunderbird.core.file + +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 +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.toKmpUri())) + val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } + sink.write(writeBuffer, writeBuffer.size) + sink.flush() + sink.close() + + val source = checkNotNull(testSubject.openSource(uri.toKmpUri())) + 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) + } + + @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/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/FileSystemManager.kt b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt new file mode 100644 index 00000000000..f37f0fa3b59 --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/FileSystemManager.kt @@ -0,0 +1,31 @@ +package net.thunderbird.core.file + +import com.eygraber.uri.Uri +import kotlinx.io.RawSink +import kotlinx.io.RawSource + +/** + * An interface for file system operations that are platform-specific. + */ +interface FileSystemManager { + /** + * Opens a sink for writing to a URI. + * + * 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, mode: WriteMode = WriteMode.Truncate): RawSink? + + /** + * Opens a source for reading from a URI. + * + * @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(uri: Uri): RawSource? +} 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 new file mode 100644 index 00000000000..bdfd624cf18 --- /dev/null +++ b/core/file/src/commonMain/kotlin/net/thunderbird/core/file/command/CopyCommand.kt @@ -0,0 +1,64 @@ +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.file.WriteMode +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) + ?: return Outcome.Failure( + FileOperationError.Unavailable(sourceUri, "Unable to open source: $sourceUri"), + ) + val sink = fs.openSink(destinationUri, WriteMode.Truncate) + ?: 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 (e: Exception) { + return Outcome.Failure(FileOperationError.ReadFailed(sourceUri, e.message), cause = e) + } + if (read <= 0L) break + try { + sink.write(buffer, read) + } catch (e: Exception) { + return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, e.message), cause = e) + } + } + try { + sink.flush() + } catch (e: Exception) { + return Outcome.Failure(FileOperationError.WriteFailed(destinationUri, e.message), cause = e) + } + Outcome.Success(Unit) + } catch (e: Exception) { + Outcome.Failure(FileOperationError.Unknown(e.message), cause = e) + } finally { + try { + source.close() + } catch (_: Exception) {} + try { + sink.close() + } catch (_: Exception) {} + } + } + + 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..ff61e3f50bb --- /dev/null +++ b/core/file/src/commonTest/kotlin/net/thunderbird/core/file/FakeFileSystemManager.kt @@ -0,0 +1,68 @@ +package net.thunderbird.core.file + +import com.eygraber.uri.Uri +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(uri: Uri, mode: WriteMode): RawSink? { + val key = uri.toString() + return object : RawSink { + 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 + val count = byteCount.toInt() + repeat(count) { + if (source.size <= 0L) return + collected += source.readByte() + } + } + + override fun flush() { + storage[key] = collected.toByteArray() + } + + override fun close() { + // ensure data is stored + flush() + } + } + } + + 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 { + 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/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..e5e2e3ce58f --- /dev/null +++ b/core/file/src/jvmMain/kotlin/net/thunderbird/core/file/JvmFileSystemManager.kt @@ -0,0 +1,41 @@ +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 +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(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 = when (mode) { + WriteMode.Truncate -> false + WriteMode.Append -> true + } + FileOutputStream(file, append).asSink() + } catch (_: Throwable) { + null + } + } + + override fun openSource(uri: Uri): RawSource? { + return try { + 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 new file mode 100644 index 00000000000..596272f21fd --- /dev/null +++ b/core/file/src/jvmTest/kotlin/net/thunderbird/core/file/JvmFileSystemManagerTest.kt @@ -0,0 +1,125 @@ +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 +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 uri = Uri.parse(tempFile.toURI().toString()) + val sink = checkNotNull(testSubject.openSink(uri)) + + // Act + val writeBuffer = Buffer().apply { write(testText.encodeToByteArray()) } + sink.write(writeBuffer, writeBuffer.size) + sink.flush() + sink.close() + + val source = checkNotNull(testSubject.openSource(uri)) + 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) + } + + @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) + } +} diff --git a/core/logging/impl-file/build.gradle.kts b/core/logging/impl-file/build.gradle.kts index cc2a55ff65b..0a9c444bf11 100644 --- a/core/logging/impl-file/build.gradle.kts +++ b/core/logging/impl-file/build.gradle.kts @@ -9,8 +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 df564228c95..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,19 +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.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 { @@ -93,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) @@ -116,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/AndroidFileSystemManager.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt deleted file mode 100644 index fe61e2ec87c..00000000000 --- a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt +++ /dev/null @@ -1,19 +0,0 @@ -package net.thunderbird.core.logging.file - -import android.content.ContentResolver -import android.net.Uri -import androidx.core.net.toUri -import kotlinx.io.RawSink -import kotlinx.io.asSink - -/** - * Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations. - */ -class AndroidFileSystemManager( - private val contentResolver: ContentResolver, -) : FileSystemManager { - override fun openSink(uriString: String, mode: String): RawSink? { - val uri: Uri = uriString.toUri() - return contentResolver.openOutputStream(uri, mode)?.asSink() - } -} 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..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,12 +1,18 @@ package net.thunderbird.core.logging.file +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 cdd9e18a468..00000000000 --- a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt +++ /dev/null @@ -1,32 +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 - -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 - } - } -} 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..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,15 +1,17 @@ package net.thunderbird.core.logging.file +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 @@ -26,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/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt deleted file mode 100644 index aaca95a03dc..00000000000 --- a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt +++ /dev/null @@ -1,17 +0,0 @@ -package net.thunderbird.core.logging.file - -import kotlinx.io.RawSink - -/** - * An interface for file system operations that are platform-specific. - */ -interface FileSystemManager { - /** - * Opens a sink for writing to a URI. - * - * @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? -} 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..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,20 +1,13 @@ package net.thunderbird.core.logging.file +import net.thunderbird.core.file.FileManager 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, 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/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/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/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) 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") 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",