Skip to content
Open
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
Expand Up @@ -182,8 +182,6 @@ internal data class PendingExport(
val mimeType: String,
val cacheFile: File,
) {
fun readContent(): String = cacheFile.readText(StandardCharsets.UTF_8)

fun deleteCache() {
if (cacheFile.exists() && !cacheFile.delete()) {
Log.w(TAG, "Failed to delete export cache ${cacheFile.name}")
Expand Down Expand Up @@ -279,6 +277,31 @@ internal fun persistExportPayload(cacheDir: File, content: String): File {
return persistExportStream(cacheDir, content.byteInputStream(StandardCharsets.UTF_8))
}

internal const val SHARED_EXPORTS_DIR = "shared-exports"

// Shared copies are only needed until the receiving app has read them.
internal const val SHARED_EXPORT_MAX_AGE_MS = 24 * 60 * 60 * 1000L

/**
* Moves [pending]'s cache file to `<sharedRoot>/<unique>/<filename>` so it can be shared
* under its real name, and returns it (null if the move failed). Each share gets its own
* directory so exports with the same filename never overwrite each other. Directories
* older than [SHARED_EXPORT_MAX_AGE_MS] are removed first.
*/
internal fun moveExportForSharing(
sharedRoot: File,
pending: PendingExport,
now: Long = System.currentTimeMillis(),
): File? {
sharedRoot.listFiles()?.forEach { dir ->
if (now - dir.lastModified() > SHARED_EXPORT_MAX_AGE_MS) dir.deleteRecursively()
}
val dir = File(sharedRoot, java.util.UUID.randomUUID().toString())
if (!dir.mkdirs()) return null
val target = File(dir, pending.filename)
return target.takeIf { pending.cacheFile.renameTo(it) }
}

internal fun persistExportStream(cacheDir: File, input: java.io.InputStream): File {
val dir = File(cacheDir, "exports").apply { mkdirs() }
val file = File(dir, "${java.util.UUID.randomUUID()}.export")
Expand Down Expand Up @@ -706,7 +729,6 @@ class WebUIFragment : Fragment() {
Log.e(TAG, "CreateDocument failed, falling back to share sheet", e)
exportQueue.completeInFlight()
shareExport(next)
next.deleteCache()
launchNextExportPicker()
}
}
Expand Down Expand Up @@ -739,21 +761,27 @@ class WebUIFragment : Fragment() {
launchNextExportPicker()
}

// Opens the share sheet for an export when the system file picker is unavailable.
// The cached export is moved, not copied, into a shareable location: a rename within
// cacheDir is instant, so this stays synchronous on the UI thread. That avoids reading
// large exports into memory (#304) and leaves no background work to race a second
// export or be lost if the activity is recreated.
private fun shareExport(pending: PendingExport) {
val ctx = context ?: return
val externalDir = ctx.getExternalFilesDir(null) ?: run {
Log.e(TAG, "External files directory unavailable")
showExportToast(getString(R.string.export_save_failed), long = true)
val ctx = context ?: run {
pending.deleteCache()
return
}
val file = File(externalDir, pending.filename)
try {
file.writeText(pending.readContent())
} catch (e: Exception) {
Log.e(TAG, "Failed to write export file: ${e.message}")
val file = moveExportForSharing(File(ctx.cacheDir, SHARED_EXPORTS_DIR), pending)
if (file == null) {
Log.e(TAG, "Failed to prepare ${pending.filename} for sharing")
pending.deleteCache()
showExportToast(getString(R.string.export_save_failed), long = true)
return
}
startShareChooser(ctx, file, pending)
}

private fun startShareChooser(ctx: Context, file: File, pending: PendingExport) {
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.provider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = pending.mimeType
Expand Down
1 change: 1 addition & 0 deletions mobile/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="external_files" path="." />
<cache-path name="shared_exports" path="shared-exports/" />
</paths>
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ class WebUIFragmentTest {
assertEquals(first, restored.inFlight)
assertEquals(first, restored.completeInFlight())
assertEquals(second, restored.beginNext())
assertEquals("one", first.readContent())
assertEquals("two", second.readContent())
assertEquals("one", first.cacheFile.readText())
assertEquals("two", second.cacheFile.readText())
}

@Test
Expand Down Expand Up @@ -274,6 +274,35 @@ class WebUIFragmentTest {
assertEquals(listOf("/api/0/export" to "aw-bucket-export.json"), received)
}

@Test
fun `shared exports with the same filename don't overwrite each other`() {
val cache = createTempDir()
val sharedRoot = File(cache, SHARED_EXPORTS_DIR)
val first = cachedExport(File(cache, "a").apply { mkdirs() }, "aw-bucket-export.json", "one")
val second = cachedExport(File(cache, "b").apply { mkdirs() }, "aw-bucket-export.json", "two")

val firstShared = moveExportForSharing(sharedRoot, first)!!
val secondShared = moveExportForSharing(sharedRoot, second)!!

assertEquals("aw-bucket-export.json", firstShared.name)
assertEquals("one", firstShared.readText())
assertEquals("two", secondShared.readText())
assertFalse(first.cacheFile.exists())
}

@Test
fun `sharing an export removes shared exports older than a day`() {
val cache = createTempDir()
val sharedRoot = File(cache, SHARED_EXPORTS_DIR)
val old = File(sharedRoot, "old").apply { mkdirs() }
File(old, "stale.json").writeText("stale")
val now = old.lastModified() + SHARED_EXPORT_MAX_AGE_MS + 1

moveExportForSharing(sharedRoot, cachedExport(cache, "fresh.json", "fresh"), now)

assertFalse(old.exists())
}

private fun cachedExport(dir: File, name: String, content: String): PendingExport {
return PendingExport(name, "application/json", File(dir, name).also { it.writeText(content) })
}
Expand Down
Loading