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
4 changes: 4 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ kotlin {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.okio)
implementation(libs.multiplatform.markdown.renderer.m3)
implementation(libs.multiplatform.markdown.renderer.coil3)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor)
}

val nonWebMain by creating {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import io.github.smiling_pixel.database.InMemoryFileMetadataDao
import io.github.smiling_pixel.client.GoogleWeatherClient
import io.github.smiling_pixel.client.WeatherClient
import io.github.smiling_pixel.preference.getSettingsRepository
import coil3.compose.setSingletonImageLoaderFactory

@Serializable
sealed interface AppRoute
Expand All @@ -64,6 +65,10 @@ fun App(
providedRepo: io.github.smiling_pixel.database.DiaryRepository? = null,
providedFileRepo: FileRepository? = null
) {
setSingletonImageLoaderFactory { context ->
getAsyncImageLoader(context)
Comment on lines +68 to +69
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setSingletonImageLoaderFactory is called inside the App composable function without any remember or LaunchedEffect wrapper. This means it will be called on every recomposition of the App composable, which is inefficient. Consider wrapping it in a LaunchedEffect with an empty dependency list to ensure it's only called once, or moving it outside the composable to an initialization location.

Suggested change
setSingletonImageLoaderFactory { context ->
getAsyncImageLoader(context)
LaunchedEffect(Unit) {
setSingletonImageLoaderFactory { context ->
getAsyncImageLoader(context)
}

Copilot uses AI. Check for mistakes.
}

MaterialTheme {
val repo = providedRepo ?: remember { DiaryRepository(InMemoryDiaryDao()) }
val fileRepo = providedFileRepo ?: remember {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ package io.github.smiling_pixel
import io.github.smiling_pixel.model.DiaryEntry
import io.github.smiling_pixel.client.WeatherClient
import io.github.smiling_pixel.model.Location
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import kotlin.time.Clock
import kotlin.time.Instant
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect import: This should be kotlinx.datetime.Instant, not kotlin.time.Instant. The code uses Instant.fromEpochMilliseconds() which is a method from kotlinx.datetime.Instant. Using the wrong import will cause a compilation error.

Suggested change
import kotlin.time.Instant
import kotlinx.datetime.Instant

Copilot uses AI. Check for mistakes.
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.Instant
import kotlinx.coroutines.launch

import androidx.compose.foundation.clickable
Expand Down Expand Up @@ -173,6 +175,7 @@ fun EntryDetailsScreen(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
placeholder = { Text("Enter title...") },
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.headlineMedium
)
Expand Down Expand Up @@ -269,6 +272,7 @@ fun EntryDetailsScreen(
value = content,
onValueChange = { content = it },
label = { Text("Content") },
placeholder = { Text("Type anything... Markdown is supported.") },
modifier = Modifier.fillMaxSize()
)
} else {
Expand Down Expand Up @@ -314,9 +318,9 @@ fun EntryDetailsScreen(

Spacer(modifier = Modifier.height(12.dp))

Text(
text = entry.content,
style = MaterialTheme.typography.bodyMedium,
Markdown(
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Markdown component is missing a modifier parameter. In edit mode, the corresponding OutlinedTextField uses Modifier.fillMaxSize() to fill the available space. For consistency and to ensure the Markdown content is properly laid out, consider adding a modifier parameter such as modifier = Modifier.fillMaxWidth() to ensure the Markdown content has proper width constraints.

Suggested change
Markdown(
Markdown(
modifier = Modifier.fillMaxWidth(),

Copilot uses AI. Check for mistakes.
content = entry.content,
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Markdown component doesn't specify a modifier, which means it won't have any layout constraints applied. Consider adding a modifier parameter to control the layout behavior, especially since this is in a Column that could have scrolling or size requirements.

Suggested change
content = entry.content,
content = entry.content,
modifier = Modifier.fillMaxWidth(),

Copilot uses AI. Check for mistakes.
imageTransformer = Coil3ImageTransformerImpl,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.github.smiling_pixel

import coil3.ImageLoader
import coil3.PlatformContext
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import io.github.smiling_pixel.filesystem.LocalFileFetcher
import io.github.smiling_pixel.filesystem.fileManager

fun getAsyncImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(LocalFileFetcher.Factory(fileManager)) // use localfile scheme, e.g., `localfile://myimage.jpg`, see LocalFileFetcher implementation for details
add(KtorNetworkFetcherFactory())
}
.crossfade(true)
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.github.smiling_pixel.filesystem.FileRepository
import io.github.smiling_pixel.model.FileMetadata
import kotlinx.datetime.Instant
import kotlin.time.Instant
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect import: This should be kotlinx.datetime.Instant, not kotlin.time.Instant. The code at line 189 uses Instant.fromEpochMilliseconds() which is a method from kotlinx.datetime.Instant. Using the wrong import will cause a compilation error.

Suggested change
import kotlin.time.Instant
import kotlinx.datetime.Instant

Copilot uses AI. Check for mistakes.
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package io.github.smiling_pixel.filesystem

import coil3.ImageLoader
import coil3.Uri
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.Fetcher
import coil3.fetch.FetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import okio.Buffer
import okio.FileSystem
import okio.Path
import okio.IOException

class LocalFileFetcher(
private val fileName: String,
private val fileManager: FileManager
) : Fetcher {

override suspend fun fetch(): FetchResult? {
val bytes = fileManager.read(fileName) ?: run {
val errorMessage = "LocalFileFetcher: File not found: $fileName"
println(errorMessage)
// Returning null here would let Coil try other fetchers, but since we handle
// the 'localfile' scheme, no other fetcher is expected to succeed.
// Throwing an exception provides a more informative error result.
throw IOException(errorMessage)
}
val buffer = Buffer().write(bytes)

return SourceFetchResult(
source = ImageSource(buffer, EmptyFileSystem),
mimeType = null,
dataSource = DataSource.DISK
)
}

/**
* Factory for creating [LocalFileFetcher] instances for URIs with the `localfile` scheme.
*
* Expected formats include:
* - `localfile:image.jpg`
* - `localfile:/image.jpg`
* - `localfile:///image.jpg`
*
* In all cases, [Uri.path] is used and any leading '/' characters are trimmed before
* being passed to [FileManager]. Only the `localfile` scheme is recognized here.
*/
class Factory(private val fileManager: FileManager) : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
if (data.scheme == "localfile") {
// data.path might start with /, e.g. /image.jpg
val fileName = data.path?.trimStart('/') ?: run {
println("LocalFileFetcher: Empty path in URI: $data")
return null
}

// Reject potentially unsafe paths to prevent directory traversal.
// This ensures inputs like "../secret.png" or "a/../../etc/passwd" are not used.
if (fileName.isEmpty() || fileName.contains("..")) {
println("LocalFileFetcher: Rejected potentially unsafe or empty path: $fileName")
return null
}
return LocalFileFetcher(fileName, fileManager)
}
return null
}
}
}

/**
* A dummy [FileSystem] implementation used for [ImageSource] when the data is already buffered in memory.
*
* Since we load the file content into an Okio [Buffer] using [FileManager] and pass that buffer to [ImageSource],
* Coil does not need to read from the file system directly for this source.
* This implementation safely returns null or throws strict exceptions to ensure no unintended file system usage occurs.
*/
object EmptyFileSystem : FileSystem() {
override fun canonicalize(path: Path) = path
override fun metadataOrNull(path: Path) = null
override fun list(dir: Path) = throw IOException("Not supported")
override fun listOrNull(dir: Path): List<Path>? = null
override fun openReadOnly(file: Path) = throw IOException("Not supported")
override fun openReadWrite(file: Path, mustCreate: Boolean, mustExist: Boolean) = throw IOException("Not supported")
override fun source(file: Path) = throw IOException("Not supported")
override fun sink(file: Path, mustCreate: Boolean) = throw IOException("Not supported")
override fun appendingSink(file: Path, mustExist: Boolean) = throw IOException("Not supported")
override fun createDirectory(dir: Path, mustCreate: Boolean) = throw IOException("Not supported")
override fun atomicMove(source: Path, target: Path) = throw IOException("Not supported")
override fun delete(path: Path, mustExist: Boolean) = throw IOException("Not supported")
override fun createSymlink(source: Path, target: Path) = throw IOException("Not supported")
Comment on lines +82 to +92
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EmptyFileSystem implementation throws IOException for unsupported operations, but this exception type is imported from okio.IOException. While this is functional, consider if these operations should throw UnsupportedOperationException instead, which more accurately represents that these operations are intentionally not supported rather than an I/O failure occurred.

Copilot uses AI. Check for mistakes.
}
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ okio = "3.16.2"
room = "2.8.4"
ksp = "2.3.3"
sqlite = "2.6.2"
markdown-renderer = "0.27.0"
coil = "3.0.4"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
Expand Down Expand Up @@ -47,6 +49,10 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
multiplatform-markdown-renderer-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdown-renderer" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
Expand Down
Loading