@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- 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
|
+
}
|