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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.github.smiling_pixel.sync

import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import io.github.smiling_pixel.preference.AndroidContextProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

internal actual suspend fun writeDiaryEntryExportFiles(
files: List<DiaryEntryExportFile>
): DiaryEntryExportResult {
return try {
val context = AndroidContextProvider.context

val uris: ArrayList<Uri> = withContext(Dispatchers.IO) {
val exportDir = File(context.cacheDir, "entry_exports").also {
if (it.exists()) {
it.deleteRecursively()
}
it.mkdirs()
}
ArrayList<Uri>(files.size).apply {
for (file in files) {
val exportFile = File(exportDir, file.fileName)
exportFile.writeBytes(file.content)
add(
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
exportFile
)
)
}
}
}

val sendIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "text/plain"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
putExtra(Intent.EXTRA_SUBJECT, "MarkDay diary entries")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(sendIntent, "Export MarkDay diary entries").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(chooser)

DiaryEntryExportResult.Success(files.size, "Android share sheet")
} catch (e: Exception) {
DiaryEntryExportResult.Failure(e.message ?: "Unable to export diary entries.")
}
}
1 change: 1 addition & 0 deletions composeApp/src/androidMain/res/xml/log_export_paths.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="log_exports" path="log_exports/" />
<cache-path name="entry_exports" path="entry_exports/" />
</paths>
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ fun App(
InsightsScreen()
}
composable<SettingsRoute> {
SettingsScreen()
SettingsScreen(repo = repo)
}
composable<ProfileRoute> { backStackEntry ->
ProfileScreen(onBack = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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(LocalFileFetcher.Factory(fileManager)) // use localfile scheme, e.g., `localfile:///myimage.jpg`
add(KtorNetworkFetcherFactory())
}
.crossfade(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.github.smiling_pixel.database

import io.github.smiling_pixel.model.DiaryEntry
import io.github.smiling_pixel.preference.SettingsRepository
import io.github.smiling_pixel.preference.getSettingsRepository
import io.github.smiling_pixel.sync.recordLocalDeletionTombstone
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -15,7 +17,8 @@ import kotlinx.coroutines.launch
*/
class DiaryRepository(
private val dao: IDiaryDao,
scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
private val settings: SettingsRepository = getSettingsRepository()
) {
private val _entries = MutableStateFlow<List<DiaryEntry>>(emptyList())
val entries: StateFlow<List<DiaryEntry>> = _entries
Expand Down Expand Up @@ -47,7 +50,7 @@ class DiaryRepository(

suspend fun delete(entry: DiaryEntry, recordSyncTombstone: Boolean = true) {
if (recordSyncTombstone) {
recordLocalDeletionTombstone(entry.syncId)
recordLocalDeletionTombstone(entry.syncId, settings = settings)
}
dao.delete(entry)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class LocalFileFetcher(
override suspend fun fetch(): FetchResult? {
val bytes = fileManager.read(fileName) ?: run {
val errorMessage = "LocalFileFetcher: File not found: $fileName"
Logger.e("LocalFileFetcher", errorMessage)
runCatching { Logger.e("LocalFileFetcher", 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.
Expand All @@ -43,36 +43,48 @@ class LocalFileFetcher(
/**
* Factory for creating [LocalFileFetcher] instances for URIs with the `localfile` scheme.
*
* Expected formats include:
* - `localfile:image.jpg`
* - `localfile:/image.jpg`
* - `localfile:///image.jpg`
* Expected format: `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.
* Only legal hierarchical URIs with the `localfile` scheme, no authority, and a single path segment are
* recognized here. The parsed URI path is passed to [FileManager].
*/
class Factory(private val fileManager: FileManager) : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return createForUri(data)
}

internal fun createForUri(data: Uri): Fetcher? {
if (data.scheme == "localfile") {
// data.path might start with /, e.g. /image.jpg
val fileName = data.path?.trimStart('/') ?: run {
Logger.w("LocalFileFetcher", "Empty path in URI: $data")
return null
}
if (data.scheme != LOCAL_FILE_SCHEME) {
return null
}

if (!data.authority.isNullOrEmpty()) {
Logger.w("LocalFileFetcher", "Rejected localfile URI with authority: $data")
return null
}

val fileName = localFileName(data.path) ?: run {
Logger.w("LocalFileFetcher", "Expected exactly one path segment 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("..")) {
Logger.w("LocalFileFetcher", "Rejected potentially unsafe or empty path: $fileName")
return null
}
return LocalFileFetcher(fileName, fileManager)
// Reject potentially unsafe paths to prevent directory traversal.
if (fileName.contains("..")) {
Logger.w("LocalFileFetcher", "Rejected potentially unsafe path: $fileName")
return null
}
return null
return LocalFileFetcher(fileName, fileManager)
}

private fun localFileName(path: String?): String? {
if (path == null || !path.startsWith("/") || path.indexOf('/', startIndex = 1) != -1) {
return null
}
return path.removePrefix("/").takeIf { it.isNotEmpty() }
}

private companion object {
const val LOCAL_FILE_SCHEME = "localfile"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ fun EntryDetailsScreen(
LaunchedEffect(entry!!.content) {
var count = 0
var size = 0L
val regex = Regex("localfile:/*([^)\\s]+)")
val regex = Regex("localfile:///([^)/\\s]+)")
val matches = regex.findAll(entry.content)
for (match in matches) {
val filePath = match.groupValues[1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,19 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.Lifecycle
import io.github.smiling_pixel.client.UserInfo
import io.github.smiling_pixel.client.getCloudDriveClient
import io.github.smiling_pixel.database.DiaryRepository
import io.github.smiling_pixel.getPlatform
import io.github.smiling_pixel.preference.getSettingsRepository
import io.github.smiling_pixel.sync.DiaryEntryExportResult
import io.github.smiling_pixel.sync.exportDiaryEntries
import io.github.smiling_pixel.util.LogExportResult
import io.github.smiling_pixel.util.LogLevel
import io.github.smiling_pixel.util.Logger
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.launch

@Composable
fun SettingsScreen() {
fun SettingsScreen(repo: DiaryRepository) {
val scope = rememberCoroutineScope()
// Remember the settings repository so recomposition does not recreate a new DataStore-backed
// repository instance and resubscribe all mapped flows unnecessarily.
Expand Down Expand Up @@ -460,6 +463,24 @@ fun SettingsScreen() {

Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
scope.launch {
diagnosticsMessage = when (val result = exportDiaryEntries(repo.entries.value)) {
is DiaryEntryExportResult.Success -> {
"Exported ${result.fileCount} diary entries to ${result.destinationDescription}."
}
DiaryEntryExportResult.NoEntries -> "No diary entries to export."
DiaryEntryExportResult.Unavailable -> {
"Diary entry export is unavailable on this platform."
}
is DiaryEntryExportResult.Failure -> result.message
}
}
}
) {
Text("Export Diary Entries")
}
Button(
onClick = {
scope.launch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package io.github.smiling_pixel.sync

import io.github.smiling_pixel.model.DiaryEntry
import kotlin.time.ExperimentalTime

/**
* Represents the result of exporting diary entries as sync-compatible files.
*/
sealed interface DiaryEntryExportResult {
/**
* Indicates that diary entries were exported successfully.
*
* @property fileCount Number of diary entry files exported.
* @property destinationDescription User-readable destination description.
*/
data class Success(
val fileCount: Int,
val destinationDescription: String
) : DiaryEntryExportResult

/**
* Indicates that there are no diary entries available to export.
*/
data object NoEntries : DiaryEntryExportResult

/**
* Indicates that diary entry export is not available on the current platform.
*/
data object Unavailable : DiaryEntryExportResult

/**
* Indicates that diary entry export failed.
*
* @property message A user-readable failure message.
*/
data class Failure(val message: String) : DiaryEntryExportResult
}

/**
* Exports active diary entries as the same human-readable files used by cloud sync.
*
* @param entries Current local diary entries to export.
* @return The export result.
*/
suspend fun exportDiaryEntries(entries: List<DiaryEntry>): DiaryEntryExportResult {
val files = buildDiaryEntryExportFiles(entries)
if (files.isEmpty()) {
return DiaryEntryExportResult.NoEntries
}
return writeDiaryEntryExportFiles(files)
}

@OptIn(ExperimentalTime::class)
internal fun buildDiaryEntryExportFiles(entries: List<DiaryEntry>): List<DiaryEntryExportFile> {
return entries
.sortedWith(
compareBy<DiaryEntry> { it.entryDate }
.thenBy { it.updatedAt }
.thenBy { it.syncId }
)
.map { entry ->
DiaryEntryExportFile(
fileName = buildSyncEntryFileName(entry.syncId, entry.updatedAt.toEpochMilliseconds()),
content = encodeEntryForSync(entry)
)
}
}

internal data class DiaryEntryExportFile(
val fileName: String,
val content: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DiaryEntryExportFile) return false
if (fileName != other.fileName) return false
return content.contentEquals(other.content)
}

override fun hashCode(): Int {
var result = fileName.hashCode()
result = 31 * result + content.contentHashCode()
return result
}
}

internal expect suspend fun writeDiaryEntryExportFiles(
files: List<DiaryEntryExportFile>
): DiaryEntryExportResult
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ suspend fun performCloudSync(
if (remote != null) {
val (driveFile, remoteTime) = remote
if (localTime > remoteTime) {
val name = buildRemoteEntryFileName(local.syncId, localTime)
val name = buildSyncEntryFileName(local.syncId, localTime)
client.createFile(name, encodeEntryForSync(local), SYNC_ENTRY_MIME_TYPE, parentId)
// Delete old files only after successful upload to avoid losing the only remote copy.
val oldVersions = remoteFileVersions[local.syncId].orEmpty()
Expand All @@ -145,7 +145,7 @@ suspend fun performCloudSync(
}
remoteFileMap.remove(local.syncId)
} else {
val name = buildRemoteEntryFileName(local.syncId, localTime)
val name = buildSyncEntryFileName(local.syncId, localTime)
client.createFile(name, encodeEntryForSync(local), SYNC_ENTRY_MIME_TYPE, parentId)
uploaded++
}
Expand Down Expand Up @@ -238,7 +238,14 @@ private suspend fun getOrCreateFolderByPath(client: CloudDriveClient, path: Stri
return currentParentId
}

private fun buildRemoteEntryFileName(syncId: String, timestampMillis: Long): String {
/**
* Builds the cloud-sync file name used for a diary entry payload.
*
* @param syncId Stable cross-device entry identifier.
* @param timestampMillis Entry update timestamp in epoch milliseconds.
* @return The sync-compatible entry file name.
*/
internal fun buildSyncEntryFileName(syncId: String, timestampMillis: Long): String {
return "${SYNC_ENTRY_FILE_PREFIX}${syncId}_${timestampMillis}${SYNC_ENTRY_FILE_EXTENSION}"
}

Expand Down Expand Up @@ -412,4 +419,4 @@ private suspend fun quarantineLegacyFile(
client.deleteFile(driveFile.id)
}
return legacyName
}
}
Loading
Loading