Skip to content

Commit 820cf14

Browse files
authored
Merge pull request #32 from SmilingPixel/feat/file_export_0614
feat: diary entry export functionality and settings integration
2 parents 853fee1 + e51d700 commit 820cf14

15 files changed

Lines changed: 412 additions & 52 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package io.github.smiling_pixel.sync
2+
3+
import android.content.Intent
4+
import android.net.Uri
5+
import androidx.core.content.FileProvider
6+
import io.github.smiling_pixel.preference.AndroidContextProvider
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.withContext
9+
import java.io.File
10+
11+
internal actual suspend fun writeDiaryEntryExportFiles(
12+
files: List<DiaryEntryExportFile>
13+
): DiaryEntryExportResult {
14+
return try {
15+
val context = AndroidContextProvider.context
16+
17+
val uris: ArrayList<Uri> = withContext(Dispatchers.IO) {
18+
val exportDir = File(context.cacheDir, "entry_exports").also {
19+
if (it.exists()) {
20+
it.deleteRecursively()
21+
}
22+
it.mkdirs()
23+
}
24+
ArrayList<Uri>(files.size).apply {
25+
for (file in files) {
26+
val exportFile = File(exportDir, file.fileName)
27+
exportFile.writeBytes(file.content)
28+
add(
29+
FileProvider.getUriForFile(
30+
context,
31+
"${context.packageName}.fileprovider",
32+
exportFile
33+
)
34+
)
35+
}
36+
}
37+
}
38+
39+
val sendIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
40+
type = "text/plain"
41+
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
42+
putExtra(Intent.EXTRA_SUBJECT, "MarkDay diary entries")
43+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
44+
}
45+
val chooser = Intent.createChooser(sendIntent, "Export MarkDay diary entries").apply {
46+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
47+
}
48+
context.startActivity(chooser)
49+
50+
DiaryEntryExportResult.Success(files.size, "Android share sheet")
51+
} catch (e: Exception) {
52+
DiaryEntryExportResult.Failure(e.message ?: "Unable to export diary entries.")
53+
}
54+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<paths xmlns:android="http://schemas.android.com/apk/res/android">
33
<cache-path name="log_exports" path="log_exports/" />
4+
<cache-path name="entry_exports" path="entry_exports/" />
45
</paths>

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ fun App(
238238
InsightsScreen()
239239
}
240240
composable<SettingsRoute> {
241-
SettingsScreen()
241+
SettingsScreen(repo = repo)
242242
}
243243
composable<ProfileRoute> { backStackEntry ->
244244
ProfileScreen(onBack = {

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/ImageLoader.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import io.github.smiling_pixel.filesystem.fileManager
1010
fun getAsyncImageLoader(context: PlatformContext): ImageLoader {
1111
return ImageLoader.Builder(context)
1212
.components {
13-
add(LocalFileFetcher.Factory(fileManager)) // use localfile scheme, e.g., `localfile://myimage.jpg`, see LocalFileFetcher implementation for details
13+
add(LocalFileFetcher.Factory(fileManager)) // use localfile scheme, e.g., `localfile:///myimage.jpg`
1414
add(KtorNetworkFetcherFactory())
1515
}
1616
.crossfade(true)

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/database/DiaryRepository.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.github.smiling_pixel.database
22

33
import io.github.smiling_pixel.model.DiaryEntry
4+
import io.github.smiling_pixel.preference.SettingsRepository
5+
import io.github.smiling_pixel.preference.getSettingsRepository
46
import io.github.smiling_pixel.sync.recordLocalDeletionTombstone
57
import kotlinx.coroutines.CoroutineScope
68
import kotlinx.coroutines.Dispatchers
@@ -15,7 +17,8 @@ import kotlinx.coroutines.launch
1517
*/
1618
class DiaryRepository(
1719
private val dao: IDiaryDao,
18-
scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
20+
scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
21+
private val settings: SettingsRepository = getSettingsRepository()
1922
) {
2023
private val _entries = MutableStateFlow<List<DiaryEntry>>(emptyList())
2124
val entries: StateFlow<List<DiaryEntry>> = _entries
@@ -47,7 +50,7 @@ class DiaryRepository(
4750

4851
suspend fun delete(entry: DiaryEntry, recordSyncTombstone: Boolean = true) {
4952
if (recordSyncTombstone) {
50-
recordLocalDeletionTombstone(entry.syncId)
53+
recordLocalDeletionTombstone(entry.syncId, settings = settings)
5154
}
5255
dao.delete(entry)
5356
}

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcher.kt

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class LocalFileFetcher(
2525
override suspend fun fetch(): FetchResult? {
2626
val bytes = fileManager.read(fileName) ?: run {
2727
val errorMessage = "LocalFileFetcher: File not found: $fileName"
28-
Logger.e("LocalFileFetcher", errorMessage)
28+
runCatching { Logger.e("LocalFileFetcher", errorMessage) }
2929
// Returning null here would let Coil try other fetchers, but since we handle
3030
// the 'localfile' scheme, no other fetcher is expected to succeed.
3131
// Throwing an exception provides a more informative error result.
@@ -43,36 +43,48 @@ class LocalFileFetcher(
4343
/**
4444
* Factory for creating [LocalFileFetcher] instances for URIs with the `localfile` scheme.
4545
*
46-
* Expected formats include:
47-
* - `localfile:image.jpg`
48-
* - `localfile:/image.jpg`
49-
* - `localfile:///image.jpg`
46+
* Expected format: `localfile:///image.jpg`.
5047
*
51-
* In all cases, [Uri.path] is used and any leading '/' characters are trimmed before
52-
* being passed to [FileManager]. Only the `localfile` scheme is recognized here.
48+
* Only legal hierarchical URIs with the `localfile` scheme, no authority, and a single path segment are
49+
* recognized here. The parsed URI path is passed to [FileManager].
5350
*/
5451
class Factory(private val fileManager: FileManager) : Fetcher.Factory<Uri> {
5552
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
5653
return createForUri(data)
5754
}
5855

5956
internal fun createForUri(data: Uri): Fetcher? {
60-
if (data.scheme == "localfile") {
61-
// data.path might start with /, e.g. /image.jpg
62-
val fileName = data.path?.trimStart('/') ?: run {
63-
Logger.w("LocalFileFetcher", "Empty path in URI: $data")
64-
return null
65-
}
57+
if (data.scheme != LOCAL_FILE_SCHEME) {
58+
return null
59+
}
60+
61+
if (!data.authority.isNullOrEmpty()) {
62+
Logger.w("LocalFileFetcher", "Rejected localfile URI with authority: $data")
63+
return null
64+
}
65+
66+
val fileName = localFileName(data.path) ?: run {
67+
Logger.w("LocalFileFetcher", "Expected exactly one path segment in URI: $data")
68+
return null
69+
}
6670

67-
// Reject potentially unsafe paths to prevent directory traversal.
68-
// This ensures inputs like "../secret.png" or "a/../../etc/passwd" are not used.
69-
if (fileName.isEmpty() || fileName.contains("..")) {
70-
Logger.w("LocalFileFetcher", "Rejected potentially unsafe or empty path: $fileName")
71-
return null
72-
}
73-
return LocalFileFetcher(fileName, fileManager)
71+
// Reject potentially unsafe paths to prevent directory traversal.
72+
if (fileName.contains("..")) {
73+
Logger.w("LocalFileFetcher", "Rejected potentially unsafe path: $fileName")
74+
return null
7475
}
75-
return null
76+
return LocalFileFetcher(fileName, fileManager)
77+
}
78+
79+
private fun localFileName(path: String?): String? {
80+
if (path == null || !path.startsWith("/") || path.indexOf('/', startIndex = 1) != -1) {
81+
return null
82+
}
83+
return path.removePrefix("/").takeIf { it.isNotEmpty() }
84+
}
85+
86+
private companion object {
87+
const val LOCAL_FILE_SCHEME = "localfile"
7688
}
7789
}
7890
}

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ fun EntryDetailsScreen(
314314
LaunchedEffect(entry!!.content) {
315315
var count = 0
316316
var size = 0L
317-
val regex = Regex("localfile:/*([^)\\s]+)")
317+
val regex = Regex("localfile:///([^)/\\s]+)")
318318
val matches = regex.findAll(entry.content)
319319
for (match in matches) {
320320
val filePath = match.groupValues[1]

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SettingsScreen.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,19 @@ import androidx.lifecycle.compose.LifecycleEventEffect
4444
import androidx.lifecycle.Lifecycle
4545
import io.github.smiling_pixel.client.UserInfo
4646
import io.github.smiling_pixel.client.getCloudDriveClient
47+
import io.github.smiling_pixel.database.DiaryRepository
4748
import io.github.smiling_pixel.getPlatform
4849
import io.github.smiling_pixel.preference.getSettingsRepository
50+
import io.github.smiling_pixel.sync.DiaryEntryExportResult
51+
import io.github.smiling_pixel.sync.exportDiaryEntries
4952
import io.github.smiling_pixel.util.LogExportResult
5053
import io.github.smiling_pixel.util.LogLevel
5154
import io.github.smiling_pixel.util.Logger
5255
import kotlin.coroutines.cancellation.CancellationException
5356
import kotlinx.coroutines.launch
5457

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

461464
Spacer(modifier = Modifier.height(12.dp))
462465
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
466+
Button(
467+
onClick = {
468+
scope.launch {
469+
diagnosticsMessage = when (val result = exportDiaryEntries(repo.entries.value)) {
470+
is DiaryEntryExportResult.Success -> {
471+
"Exported ${result.fileCount} diary entries to ${result.destinationDescription}."
472+
}
473+
DiaryEntryExportResult.NoEntries -> "No diary entries to export."
474+
DiaryEntryExportResult.Unavailable -> {
475+
"Diary entry export is unavailable on this platform."
476+
}
477+
is DiaryEntryExportResult.Failure -> result.message
478+
}
479+
}
480+
}
481+
) {
482+
Text("Export Diary Entries")
483+
}
463484
Button(
464485
onClick = {
465486
scope.launch {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package io.github.smiling_pixel.sync
2+
3+
import io.github.smiling_pixel.model.DiaryEntry
4+
import kotlin.time.ExperimentalTime
5+
6+
/**
7+
* Represents the result of exporting diary entries as sync-compatible files.
8+
*/
9+
sealed interface DiaryEntryExportResult {
10+
/**
11+
* Indicates that diary entries were exported successfully.
12+
*
13+
* @property fileCount Number of diary entry files exported.
14+
* @property destinationDescription User-readable destination description.
15+
*/
16+
data class Success(
17+
val fileCount: Int,
18+
val destinationDescription: String
19+
) : DiaryEntryExportResult
20+
21+
/**
22+
* Indicates that there are no diary entries available to export.
23+
*/
24+
data object NoEntries : DiaryEntryExportResult
25+
26+
/**
27+
* Indicates that diary entry export is not available on the current platform.
28+
*/
29+
data object Unavailable : DiaryEntryExportResult
30+
31+
/**
32+
* Indicates that diary entry export failed.
33+
*
34+
* @property message A user-readable failure message.
35+
*/
36+
data class Failure(val message: String) : DiaryEntryExportResult
37+
}
38+
39+
/**
40+
* Exports active diary entries as the same human-readable files used by cloud sync.
41+
*
42+
* @param entries Current local diary entries to export.
43+
* @return The export result.
44+
*/
45+
suspend fun exportDiaryEntries(entries: List<DiaryEntry>): DiaryEntryExportResult {
46+
val files = buildDiaryEntryExportFiles(entries)
47+
if (files.isEmpty()) {
48+
return DiaryEntryExportResult.NoEntries
49+
}
50+
return writeDiaryEntryExportFiles(files)
51+
}
52+
53+
@OptIn(ExperimentalTime::class)
54+
internal fun buildDiaryEntryExportFiles(entries: List<DiaryEntry>): List<DiaryEntryExportFile> {
55+
return entries
56+
.sortedWith(
57+
compareBy<DiaryEntry> { it.entryDate }
58+
.thenBy { it.updatedAt }
59+
.thenBy { it.syncId }
60+
)
61+
.map { entry ->
62+
DiaryEntryExportFile(
63+
fileName = buildSyncEntryFileName(entry.syncId, entry.updatedAt.toEpochMilliseconds()),
64+
content = encodeEntryForSync(entry)
65+
)
66+
}
67+
}
68+
69+
internal data class DiaryEntryExportFile(
70+
val fileName: String,
71+
val content: ByteArray
72+
) {
73+
override fun equals(other: Any?): Boolean {
74+
if (this === other) return true
75+
if (other !is DiaryEntryExportFile) return false
76+
if (fileName != other.fileName) return false
77+
return content.contentEquals(other.content)
78+
}
79+
80+
override fun hashCode(): Int {
81+
var result = fileName.hashCode()
82+
result = 31 * result + content.contentHashCode()
83+
return result
84+
}
85+
}
86+
87+
internal expect suspend fun writeDiaryEntryExportFiles(
88+
files: List<DiaryEntryExportFile>
89+
): DiaryEntryExportResult

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/sync/SyncManager.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ suspend fun performCloudSync(
120120
if (remote != null) {
121121
val (driveFile, remoteTime) = remote
122122
if (localTime > remoteTime) {
123-
val name = buildRemoteEntryFileName(local.syncId, localTime)
123+
val name = buildSyncEntryFileName(local.syncId, localTime)
124124
client.createFile(name, encodeEntryForSync(local), SYNC_ENTRY_MIME_TYPE, parentId)
125125
// Delete old files only after successful upload to avoid losing the only remote copy.
126126
val oldVersions = remoteFileVersions[local.syncId].orEmpty()
@@ -145,7 +145,7 @@ suspend fun performCloudSync(
145145
}
146146
remoteFileMap.remove(local.syncId)
147147
} else {
148-
val name = buildRemoteEntryFileName(local.syncId, localTime)
148+
val name = buildSyncEntryFileName(local.syncId, localTime)
149149
client.createFile(name, encodeEntryForSync(local), SYNC_ENTRY_MIME_TYPE, parentId)
150150
uploaded++
151151
}
@@ -238,7 +238,14 @@ private suspend fun getOrCreateFolderByPath(client: CloudDriveClient, path: Stri
238238
return currentParentId
239239
}
240240

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

@@ -412,4 +419,4 @@ private suspend fun quarantineLegacyFile(
412419
client.deleteFile(driveFile.id)
413420
}
414421
return legacyName
415-
}
422+
}

0 commit comments

Comments
 (0)