Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -12,4 +17,16 @@ val appCommonCoreModule: Module = module {
appCommonCoreLogger,
appCommonCoreUiModule,
)

single<FileSystemManager> {
AndroidFileSystemManager(
contentResolver = get<Context>().contentResolver,
)
}

single<FileManager> {
DefaultFileManager(
fileSystemManager = get(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,7 +63,7 @@ val appCommonCoreLogger = module {
level = LogLevel.DEBUG,
fileName = "thunderbird-sync-debug",
fileLocation = get<Context>().filesDir.path,
fileSystemManager = AndroidFileSystemManager(get<Context>().contentResolver),
fileManager = get(),
)
}

Expand Down
2 changes: 2 additions & 0 deletions app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions core/file/README.md
Original file line number Diff line number Diff line change
@@ -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<Unit, FileOperationError>` 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<Unit, FileOperationError>
}

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<Unit, FileOperationError>
}

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<FileSystemManager> { AndroidFileSystemManager(androidContext().contentResolver) }
single<FileManager> { 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<Unit, FileOperationError>`
- 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`.

22 changes: 22 additions & 0 deletions core/file/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading