@pictor-sdk/video-android 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,445 @@
1
+ package dev.pictor.video
2
+
3
+ import android.media.MediaExtractor
4
+ import android.media.MediaFormat
5
+ import android.system.ErrnoException
6
+ import android.system.Os
7
+ import android.system.OsConstants
8
+ import org.json.JSONObject
9
+ import java.io.File
10
+ import java.io.FileInputStream
11
+ import java.io.FileOutputStream
12
+
13
+ /**
14
+ * Getting the finished file where the caller asked for it, or not at all.
15
+ *
16
+ * ## Why this is not a copy
17
+ *
18
+ * A destination that is written in place is visible while it is half-written. A
19
+ * player that opens it sees a truncated container; a gallery scanner indexes a
20
+ * file that will change under it; a failure at 90% leaves something that looks
21
+ * like a video and is not one. So the bytes go to a staging file **in the
22
+ * destination's own directory** — same filesystem, so the last step can be a
23
+ * rename — and the target only ever appears complete.
24
+ *
25
+ * ## Why the existence check is not the guard
26
+ *
27
+ * `if (!target.exists()) write(target)` is two operations with a gap in it, and
28
+ * something else can create the target inside that gap. The preflight check
29
+ * remains, but only so a doomed export can be refused before it encodes for
30
+ * eight seconds — it is a courtesy, not the guarantee.
31
+ *
32
+ * The guarantee is `renameat2(RENAME_NOREPLACE)`: one syscall that either
33
+ * publishes the complete staging file or reports `EEXIST`. Race-safe and
34
+ * crash-safe, because there is no state between those outcomes for a process
35
+ * death to land in. `link(2)` is tried second, for filesystems that predate it.
36
+ *
37
+ * `O_CREAT | O_EXCL` followed by a rename is **not** a fallback, though it is
38
+ * race-safe and was one for a while. It is two operations, and a process that
39
+ * dies between them leaves a zero-byte file that nothing can distinguish from a
40
+ * destination somebody meant to create — so it blocks every later
41
+ * `overwrite: false` export to that path until a human deletes it. Offering it
42
+ * silently would mean the contract said atomic while the behaviour was not.
43
+ * When no atomic create-if-absent exists, the export is refused and says so.
44
+ *
45
+ * ## Why replacement is a single rename
46
+ *
47
+ * `rename(2)` over an existing path is atomic: the old contents are there until
48
+ * the instant the new ones are, and nothing observes a gap. Moving the old file
49
+ * aside first would be strictly worse — two atomic steps with a window between
50
+ * them where the destination does not exist at all.
51
+ *
52
+ * ## Paths do not appear in errors
53
+ *
54
+ * Every reason names the file and nothing above it. A message that quotes a
55
+ * directory puts it in a log, a crash report and whatever the host displays.
56
+ */
57
+ internal object GateDestination {
58
+
59
+ init {
60
+ // The same library the presentation surface lives in. Loading it here
61
+ // as well is idempotent, and means publication does not depend on the
62
+ // module having been constructed first.
63
+ runCatching { System.loadLibrary("pictor_video") }
64
+ }
65
+
66
+ /**
67
+ * `renameat2(RENAME_NOREPLACE)`: publish, or `EEXIST`. Nothing in between.
68
+ *
69
+ * Returns 0 or an errno. `ENOSYS` is a kernel with no such call; `EINVAL` is
70
+ * a kernel that has it and a filesystem that will not take the flag.
71
+ */
72
+ private external fun nativeRenameNoReplace(from: String, to: String): Int
73
+
74
+ /** Gate-only: pretend the syscall is missing, to exercise the refusal. */
75
+ @Volatile var pretendNoAtomicNoReplace: Boolean = false
76
+
77
+ /**
78
+ * Gate-only: use the pre-`renameat2` claim-then-rename sequence.
79
+ *
80
+ * Kept solely so the window it has can still be demonstrated. It is not a
81
+ * fallback — see `publish`.
82
+ */
83
+ @Volatile var legacyPlaceholderPublish: Boolean = false
84
+
85
+ /** Gate-only: run between the claim and the rename, to simulate a crash. */
86
+ @Volatile var betweenClaimAndRename: (() -> Unit)? = null
87
+
88
+ /** Gate-only: die inside the copy, which is the only window left. */
89
+ @Volatile var killDuringCopy: Boolean = false
90
+
91
+ /** The last segment. Directories are the private part. */
92
+ fun describe(path: String): String {
93
+ val trimmed = path.trimEnd('/')
94
+ val name = trimmed.substringAfterLast('/')
95
+ return if (name.isEmpty()) "(unnamed)" else name
96
+ }
97
+
98
+ /** Parsed from JavaScript, which has already refused every non-`file:` form. */
99
+ data class Target(val path: String, val overwrite: Boolean)
100
+
101
+ fun parse(json: String): Target? {
102
+ val o = runCatching { JSONObject(json) }.getOrNull() ?: return null
103
+ if (o.optString("kind") != "file") return null
104
+ val path = o.optString("path")
105
+ if (path.isEmpty()) return null
106
+ return Target(path, o.optBoolean("overwrite", false))
107
+ }
108
+
109
+ /**
110
+ * Everything knowable before eight seconds of encoding.
111
+ *
112
+ * Not the guarantee — the directory can be unmounted and the target created
113
+ * while the export runs — but the difference between failing now and failing
114
+ * after the work.
115
+ */
116
+ fun validate(target: Target): String? {
117
+ val file = File(target.path)
118
+ val name = describe(target.path)
119
+ val parent = file.parentFile
120
+ ?: return "the destination '$name' has no directory to write into"
121
+ if (!parent.exists()) return "the directory for '$name' does not exist"
122
+ if (!parent.isDirectory) return "the destination for '$name' is not a directory"
123
+ if (!parent.canWrite()) return "the directory for '$name' is not writable"
124
+ if (file.isDirectory) return "the destination '$name' is an existing directory"
125
+ if (file.exists() && !target.overwrite) {
126
+ return "'$name' already exists; pass overwrite to replace it"
127
+ }
128
+ return null
129
+ }
130
+
131
+ /** What a finished container has to be before anyone is told it is done. */
132
+ fun probe(path: String): JSONObject {
133
+ val result = JSONObject()
134
+ var extractor: MediaExtractor? = null
135
+ try {
136
+ val file = File(path)
137
+ val bytes = if (file.exists()) file.length() else 0L
138
+ extractor = MediaExtractor()
139
+ extractor.setDataSource(path)
140
+ var video = false
141
+ var audio = false
142
+ var durationUs = 0L
143
+ for (i in 0 until extractor.trackCount) {
144
+ val format = extractor.getTrackFormat(i)
145
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: ""
146
+ if (mime.startsWith("video/")) video = true
147
+ if (mime.startsWith("audio/")) audio = true
148
+ if (format.containsKey(MediaFormat.KEY_DURATION)) {
149
+ durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
150
+ }
151
+ }
152
+ result.put("readable", bytes > 0 && video)
153
+ .put("bytes", bytes)
154
+ .put("tracks", extractor.trackCount)
155
+ .put("hasVideo", video)
156
+ .put("hasAudio", audio)
157
+ .put("durationUs", durationUs)
158
+ } catch (e: Exception) {
159
+ result.put("readable", false).put("error", e.javaClass.simpleName)
160
+ } finally {
161
+ runCatching { extractor?.release() }
162
+ }
163
+ return result
164
+ }
165
+
166
+ /**
167
+ * Gate-only: try the primitive here, and report what happened.
168
+ *
169
+ * Answered by performing it in the directory an export would publish into,
170
+ * rather than inferred from an API level. Whether the kernel has the call
171
+ * and whether *this mount* accepts the flag are different questions, and
172
+ * only the second one decides anything.
173
+ */
174
+ fun probeNoReplace(directory: File): JSONObject {
175
+ val a = File(directory, ".pictor-probe-a.tmp")
176
+ val b = File(directory, ".pictor-probe-b.tmp")
177
+ val result = JSONObject()
178
+ try {
179
+ runCatching { a.writeText("probe") }
180
+ runCatching { b.delete() }
181
+ val fresh = runCatching { nativeRenameNoReplace(a.absolutePath, b.absolutePath) }
182
+ .getOrDefault(OsConstants.ENOSYS)
183
+ result.put("renameat2Fresh", fresh).put("freshName", errnoName(fresh))
184
+ // And again, onto a name that now exists: EEXIST is the whole point.
185
+ runCatching { a.writeText("probe") }
186
+ val onto = runCatching { nativeRenameNoReplace(a.absolutePath, b.absolutePath) }
187
+ .getOrDefault(OsConstants.ENOSYS)
188
+ result.put("renameat2Existing", onto).put("existingName", errnoName(onto))
189
+ result.put("supported", fresh == 0 && onto == OsConstants.EEXIST)
190
+ val linked = try {
191
+ runCatching { b.delete() }
192
+ Os.link(a.absolutePath, b.absolutePath)
193
+ 0
194
+ } catch (e: ErrnoException) {
195
+ e.errno
196
+ }
197
+ result.put("link", linked).put("linkName", errnoName(linked))
198
+ } finally {
199
+ runCatching { a.delete() }
200
+ runCatching { b.delete() }
201
+ }
202
+ return result
203
+ }
204
+
205
+ /** A staging name in the destination's directory, so publication is a rename. */
206
+ fun stagingFor(target: Target, unique: Long): File =
207
+ File(File(target.path).parentFile, ".pictor-staging-$unique.tmp")
208
+
209
+ /**
210
+ * Remove staging files a previous run did not.
211
+ *
212
+ * They live in the destination's directory, not the cache, so the temporary
213
+ * sweep never sees them — and a process killed mid-publication leaves one
214
+ * the size of the whole export. Measured after a deliberate kill: a 159 597
215
+ * byte orphan that nothing was ever going to collect.
216
+ *
217
+ * Safe to do unconditionally because the module runs one export per process
218
+ * and the directory is the app's own: any staging file present is from a run
219
+ * that is no longer happening.
220
+ */
221
+ fun sweepStaging(target: Target): Int {
222
+ val parent = File(target.path).parentFile ?: return 0
223
+ val stale = parent.listFiles { f ->
224
+ f.name.startsWith(".pictor-staging-") && f.name.endsWith(".tmp")
225
+ } ?: return 0
226
+ return stale.count { runCatching { it.delete() }.getOrDefault(false) }
227
+ }
228
+
229
+ /**
230
+ * Copy, flush, close.
231
+ *
232
+ * `sync()` before close because a rename can be durable while the bytes it
233
+ * points at are not: the metadata operation and the data are ordered
234
+ * separately, and a device that loses power between them publishes a
235
+ * complete-looking file full of nothing.
236
+ *
237
+ * Returns an error reason, or `null`. Beats as it goes, and stops when
238
+ * `keepGoing` says the job was cancelled.
239
+ */
240
+ fun stage(
241
+ source: File,
242
+ staging: File,
243
+ keepGoing: () -> Boolean,
244
+ beatEpoch: Long,
245
+ /** Gate-only: hold inside the copy so a cancel can land in it. */
246
+ hold: Boolean = false,
247
+ ): String? {
248
+ val name = describe(staging.absolutePath)
249
+ try {
250
+ FileInputStream(source).use { input ->
251
+ FileOutputStream(staging).use { output ->
252
+ val buffer = ByteArray(256 * 1024)
253
+ while (true) {
254
+ if (!keepGoing()) return "cancelled"
255
+ val read = input.read(buffer)
256
+ if (read <= 0) break
257
+ output.write(buffer, 0, read)
258
+ if (killDuringCopy) {
259
+ // The only window the settled design has: a crash
260
+ // here leaves a staging file and no target at all.
261
+ android.os.Process.killProcess(android.os.Process.myPid())
262
+ }
263
+ if (hold) {
264
+ // A deterministic hold, not a sleep and a hope: the
265
+ // copy of a two-second clip is milliseconds, and no
266
+ // amount of timing lands a cancel inside it.
267
+ while (keepGoing()) Thread.sleep(20)
268
+ return "cancelled"
269
+ }
270
+ // Copying a large file is minutes of work that moves the
271
+ // bar by one reserved share. Without this it is silence.
272
+ ExportHeartbeat.beat("publishBytes", beatEpoch)
273
+ }
274
+ output.flush()
275
+ output.fd.sync()
276
+ }
277
+ }
278
+ } catch (e: Exception) {
279
+ return "staging '$name' failed: " + e.javaClass.simpleName
280
+ }
281
+ return null
282
+ }
283
+
284
+ /**
285
+ * The last step, and the only one that makes the target appear.
286
+ *
287
+ * Returns an error reason, or `null`. Never leaves the staging file behind
288
+ * on failure, and never leaves an existing target damaged.
289
+ */
290
+ fun publish(staging: File, target: Target): String? {
291
+ val name = describe(target.path)
292
+ if (target.overwrite) {
293
+ /*
294
+ One rename, not a backup and a rename.
295
+
296
+ `rename(2)` replaces atomically: the old contents are readable
297
+ until the instant the new ones are. Moving the old file aside first
298
+ would add a window in which the destination does not exist, which
299
+ is the failure this is supposed to prevent.
300
+ */
301
+ return try {
302
+ Os.rename(staging.absolutePath, target.path)
303
+ null
304
+ } catch (e: ErrnoException) {
305
+ runCatching { staging.delete() }
306
+ "publishing '$name' failed: " + errnoName(e.errno)
307
+ }
308
+ }
309
+ /*
310
+ One syscall, because publication has to survive a power cut.
311
+
312
+ `renameat2(RENAME_NOREPLACE)` either publishes the complete staging
313
+ file or reports EEXIST. There is no state between those two outcomes,
314
+ so a process that dies mid-publication leaves either the old world or
315
+ the new one — never a claimed-but-empty name that blocks every later
316
+ export to the same path.
317
+
318
+ `link(2)` has the same property and is tried second, because it works
319
+ on filesystems old enough to predate `renameat2`. It does not work on
320
+ Android's emulated volume: /storage/emulated/0/Android/data/... is a
321
+ FUSE mount with no hard links, and the call raises EACCES on a
322
+ directory the app writes to freely.
323
+
324
+ If neither is available the export is **refused**. The alternative —
325
+ claim the name with O_CREAT|O_EXCL and then rename — is race-safe and
326
+ not crash-safe, and offering it silently would mean the contract said
327
+ atomic while the behaviour was not. See `legacyPlaceholderPublish`.
328
+ */
329
+ if (legacyPlaceholderPublish) return claimThenRename(staging, target, name)
330
+
331
+ val renamed = if (pretendNoAtomicNoReplace) OsConstants.ENOSYS
332
+ else runCatching { nativeRenameNoReplace(staging.absolutePath, target.path) }
333
+ .getOrDefault(OsConstants.ENOSYS)
334
+ if (renamed == 0) return null
335
+ if (renamed == OsConstants.EEXIST) {
336
+ runCatching { staging.delete() }
337
+ return "'$name' already exists; pass overwrite to replace it"
338
+ }
339
+ if (!noReplaceUnsupported(renamed)) {
340
+ runCatching { staging.delete() }
341
+ return "publishing '$name' failed: " + errnoName(renamed)
342
+ }
343
+
344
+ try {
345
+ Os.link(staging.absolutePath, target.path)
346
+ runCatching { staging.delete() }
347
+ return null
348
+ } catch (e: ErrnoException) {
349
+ if (e.errno == OsConstants.EEXIST) {
350
+ runCatching { staging.delete() }
351
+ return "'$name' already exists; pass overwrite to replace it"
352
+ }
353
+ if (!noReplaceUnsupported(e.errno)) {
354
+ runCatching { staging.delete() }
355
+ return "publishing '$name' failed: " + errnoName(e.errno)
356
+ }
357
+ }
358
+
359
+ /*
360
+ Refused, rather than published by a sequence that is not atomic.
361
+
362
+ Every remaining way to create a file without replacing one is two
363
+ operations, and a crash between them leaves an empty destination that
364
+ nothing can distinguish from a real one. A caller who wants that trade
365
+ can pass `overwrite: true`, which is a single atomic rename and always
366
+ available.
367
+ */
368
+ runCatching { staging.delete() }
369
+ return "'$name' cannot be written without overwrite: this filesystem " +
370
+ "has no atomic create-if-absent, so a crash could leave an empty file; " +
371
+ "pass overwrite to replace it instead"
372
+ }
373
+
374
+ /**
375
+ * The pre-`renameat2` sequence, kept only so its window stays demonstrable.
376
+ *
377
+ * `O_CREAT | O_EXCL` claims the name in one kernel operation, so the race is
378
+ * closed — but the publication is two operations, and a process that dies
379
+ * between them leaves a zero-byte file at the destination. That file looks
380
+ * exactly like one somebody meant to create, so it blocks every later
381
+ * `overwrite: false` export to the same path until a human removes it.
382
+ *
383
+ * Never reached in production. It exists because "this was measured to be
384
+ * unsafe" is worth more than "this was removed".
385
+ */
386
+ private fun claimThenRename(staging: File, target: Target, name: String): String? {
387
+ val flags = OsConstants.O_WRONLY or OsConstants.O_CREAT or OsConstants.O_EXCL
388
+ try {
389
+ val fd = Os.open(target.path, flags, 384 /* 0600 */)
390
+ runCatching { Os.close(fd) }
391
+ } catch (e: ErrnoException) {
392
+ runCatching { staging.delete() }
393
+ return if (e.errno == OsConstants.EEXIST) {
394
+ "'$name' already exists; pass overwrite to replace it"
395
+ } else {
396
+ "publishing '$name' failed: " + errnoName(e.errno)
397
+ }
398
+ }
399
+ betweenClaimAndRename?.invoke()
400
+ return try {
401
+ Os.rename(staging.absolutePath, target.path)
402
+ null
403
+ } catch (e: ErrnoException) {
404
+ runCatching { File(target.path).delete() }
405
+ runCatching { staging.delete() }
406
+ "publishing '$name' failed: " + errnoName(e.errno)
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Errnos that mean "not this operation, here" rather than "not you".
412
+ *
413
+ * `EACCES` is in here, which looks wrong for a permission error and is not.
414
+ * Measured on a Pixel 7: `link` into the app's own external files directory
415
+ * — a directory it can and does write to — raises `EACCES`, because the FUSE
416
+ * volume refuses the *operation*, not the caller.
417
+ *
418
+ * Being permissive costs nothing, because the next thing tried is another
419
+ * way of creating the same file in the same directory. What a genuinely
420
+ * unwritable directory produces is the preflight refusal, which runs before
421
+ * anything is encoded.
422
+ */
423
+ private fun noReplaceUnsupported(errno: Int): Boolean =
424
+ errno == OsConstants.ENOSYS ||
425
+ errno == OsConstants.EINVAL ||
426
+ errno == OsConstants.EPERM ||
427
+ errno == OsConstants.EACCES ||
428
+ errno == OsConstants.EOPNOTSUPP ||
429
+ errno == OsConstants.EMLINK ||
430
+ errno == OsConstants.EXDEV
431
+
432
+ /** The constant's name, never the path it was raised for. */
433
+ private fun errnoName(errno: Int): String = when (errno) {
434
+ OsConstants.ENOSPC -> "no space left on the device"
435
+ OsConstants.EACCES -> "permission denied (EACCES)"
436
+ OsConstants.EPERM -> "the operation is not permitted here (EPERM)"
437
+ OsConstants.EXDEV -> "the staging file and the destination are on different filesystems"
438
+ OsConstants.EROFS -> "the filesystem is read-only"
439
+ OsConstants.ENOENT -> "the directory no longer exists"
440
+ OsConstants.ENOSYS -> "the kernel has no renameat2"
441
+ OsConstants.EEXIST -> "the destination already exists"
442
+ 0 -> "ok"
443
+ else -> "errno " + errno
444
+ }
445
+ }