@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,185 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.media.MediaCodec
5
+ import android.media.MediaExtractor
6
+ import android.media.MediaFormat
7
+ import android.media.MediaMuxer
8
+ import android.net.Uri
9
+ import java.io.File
10
+ import java.nio.ByteBuffer
11
+ import org.json.JSONObject
12
+
13
+ /**
14
+ * One finished video file plus one finished audio file, into one MP4.
15
+ *
16
+ * **Encoded samples are copied, never decoded.** Transformer re-encodes audio
17
+ * when it combines inputs — measured: 180 shard samples became 175, of which
18
+ * only 120 were byte-identical, even with `setTransmuxAudio(true)` and a uniform
19
+ * channel layout. A second generation of lossy audio on every export is not
20
+ * something to accept because the API that caused it is the convenient one.
21
+ *
22
+ * `MediaMuxer` has no encoder. It writes the sample bytes it is given, so both
23
+ * payloads survive by construction rather than by a flag being honoured.
24
+ */
25
+ internal object GateMux {
26
+
27
+ /** Copy every sample of `videoUri`'s video track and `audioUri`'s audio track. */
28
+ fun mux(
29
+ context: Context,
30
+ videoUri: String,
31
+ audioUri: String,
32
+ output: File,
33
+ /** Drop audio samples starting at or after this, or 0 to keep them all. */
34
+ audioLimitUs: Long = 0,
35
+ /** Encoder priming to remove, in output frames. Measured, never assumed. */
36
+ audioPrimingFrames: Int = 0,
37
+ /**
38
+ * Checked between samples, so a cancel does not wait out the copy.
39
+ *
40
+ * Measured: cancelling during the mux settled in 3316 ms against a 2 s
41
+ * target, because the only thing that could observe the request was the
42
+ * checkpoint *after* the whole copy had finished.
43
+ */
44
+ keepGoing: () -> Boolean = { true },
45
+ ): String {
46
+ val out = JSONObject()
47
+ val beatEpoch = ExportHeartbeat.currentEpoch()
48
+ val video = MediaExtractor()
49
+ val audio = MediaExtractor()
50
+ var muxer: MediaMuxer? = null
51
+ try {
52
+ /*
53
+ Beats through the silent stretch.
54
+
55
+ Measured: the longest legitimate heartbeat gap in a whole export
56
+ was 5.7 s, and every instance of it was here — between the last
57
+ encoded frame and the first muxed batch, while extractors open,
58
+ tracks are added and the muxer starts. Nothing was beating, so the
59
+ watchdog would have had to tolerate six seconds of real silence
60
+ and would then have been slower than the platform it exists to
61
+ beat. Instrumenting the gap is the fix; widening the threshold
62
+ would have been the symptom's.
63
+ */
64
+ ExportHeartbeat.beat("muxBatch", beatEpoch)
65
+ open(context, video, videoUri)
66
+ ExportHeartbeat.beat("muxBatch", beatEpoch)
67
+ open(context, audio, audioUri)
68
+ ExportHeartbeat.beat("muxBatch", beatEpoch)
69
+
70
+ val videoTrack = findTrack(video, "video/")
71
+ if (videoTrack < 0) return out.put("ok", false).put("error", "no video track").toString()
72
+ val audioTrack = findTrack(audio, "audio/")
73
+ if (audioTrack < 0) return out.put("ok", false).put("error", "no audio track").toString()
74
+
75
+ video.selectTrack(videoTrack)
76
+ audio.selectTrack(audioTrack)
77
+ val videoFormat = video.getTrackFormat(videoTrack)
78
+ val audioFormat = audio.getTrackFormat(audioTrack)
79
+
80
+ muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
81
+ /*
82
+ Carry the rotation across, or the video comes out sideways.
83
+
84
+ Transformer rotates a portrait composition into a landscape coded
85
+ frame when the encoder prefers one, and records the correction as
86
+ an orientation hint on *its* container. Re-muxing into a fresh
87
+ `MediaMuxer` starts from nothing, so the hint is lost and the file
88
+ is coded landscape with no instruction to turn it back.
89
+
90
+ Measured on a 1080x1920 project: the output was coded 1920x1080
91
+ with no rotation metadata and no side data, and its pixels matched
92
+ the source rotated 90 degrees counter-clockwise — mean absolute
93
+ difference 2.30, against 12.49 for the unrotated source. Nothing
94
+ downstream could have known: the frame is landscape and says so.
95
+
96
+ `KEY_ROTATION` is what the extractor reports for the intermediate;
97
+ it is absent when there is nothing to correct, and a hint of zero
98
+ is the same as no hint.
99
+ */
100
+ val rotation =
101
+ if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
102
+ videoFormat.getInteger(MediaFormat.KEY_ROTATION)
103
+ } else {
104
+ 0
105
+ }
106
+ if (rotation != 0) muxer.setOrientationHint(rotation)
107
+ out.put("orientationHint", rotation)
108
+ val outVideo = muxer.addTrack(videoFormat)
109
+ val outAudio = muxer.addTrack(audioFormat)
110
+ muxer.start()
111
+ ExportHeartbeat.beat("muxBatch", beatEpoch)
112
+
113
+ /*
114
+ Priming removal lives in `PrimingTrim`, shared with preview
115
+ publication.
116
+
117
+ It used to live here, and only here, which is exactly why a
118
+ preview played about 43 ms behind its picture while an export of
119
+ the same project was aligned: two paths publishing audio and only
120
+ one of them correcting. See that file for why whole access units
121
+ are the only granularity available without decoding, and why the
122
+ residual is reported rather than absorbed.
123
+ */
124
+ val audioRate =
125
+ if (audioFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
126
+ audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
127
+ else 48_000
128
+ val plan = PrimingTrim.plan(audioPrimingFrames, audioRate)
129
+ plan.into(out)
130
+
131
+ // Video carries no priming: an empty plan, through the same copy.
132
+ val videoSamples = PrimingTrim.copyTrack(
133
+ video, muxer, outVideo, 0, PrimingTrim.plan(0, 0), beatEpoch, keepGoing,
134
+ )
135
+ /*
136
+ The AAC tail is dropped rather than carried.
137
+
138
+ An encoder emits whole frames, so the last one runs past the end of
139
+ the timeline -- measured, 74 ms past a compiled 4.5 s, which makes
140
+ the container longer than the project and every duration a caller
141
+ reads slightly wrong. Samples starting at or after the compiled end
142
+ are entirely after the last visible frame, so dropping them removes
143
+ the overhang without removing anything audible.
144
+ */
145
+ // Skipped entirely if the video copy stopped: there is nothing to
146
+ // finish, and copying an audio track into a file nobody will keep is
147
+ // the wait a cancel exists to avoid.
148
+ val audioSamples =
149
+ if (videoSamples < 0) -1
150
+ else PrimingTrim.copyTrack(
151
+ audio, muxer, outAudio, audioLimitUs, plan, beatEpoch, keepGoing,
152
+ )
153
+
154
+ if (videoSamples < 0 || audioSamples < 0) {
155
+ out.put("ok", false).put("cancelled", true).put("error", "cancelled during mux")
156
+ } else {
157
+ out.put("ok", true)
158
+ .put("videoSamples", videoSamples)
159
+ .put("audioSamples", audioSamples)
160
+ .put("uri", output.absolutePath)
161
+ }
162
+ } catch (e: Exception) {
163
+ out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
164
+ } finally {
165
+ runCatching { muxer?.stop() }
166
+ runCatching { muxer?.release() }
167
+ runCatching { video.release() }
168
+ runCatching { audio.release() }
169
+ }
170
+ return out.toString()
171
+ }
172
+
173
+ private fun open(context: Context, extractor: MediaExtractor, uri: String) {
174
+ if (uri.startsWith("/")) extractor.setDataSource(uri)
175
+ else extractor.setDataSource(context, Uri.parse(uri), null)
176
+ }
177
+
178
+ private fun findTrack(extractor: MediaExtractor, prefix: String): Int {
179
+ for (i in 0 until extractor.trackCount) {
180
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: ""
181
+ if (mime.startsWith(prefix)) return i
182
+ }
183
+ return -1
184
+ }
185
+ }
@@ -0,0 +1,333 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import java.io.File
6
+ import java.io.FileOutputStream
7
+ import java.security.MessageDigest
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+
11
+ /**
12
+ * Media the app owns, copied out of wherever it was picked from.
13
+ *
14
+ * ## Why a copy at all
15
+ *
16
+ * A picker hands back a reference, not a file. `content://` grants are scoped
17
+ * to the activity result that produced them and stop resolving once the process
18
+ * dies; `expo-document-picker`'s `copyToCacheDirectory` improves on that by
19
+ * writing into **cache**, which Android reclaims whenever it wants storage
20
+ * back. Both are fine until the moment someone reopens a project, and then the
21
+ * song is simply gone — with no error until an export fails.
22
+ *
23
+ * So an accepted source is copied into `filesDir`, which is app-owned and
24
+ * durable, and the project stores *that* URI. Nothing a picker returned is ever
25
+ * persisted.
26
+ *
27
+ * ## Content-addressed, deliberately
28
+ *
29
+ * The name is the SHA-256 of the bytes, never the user's filename. Three things
30
+ * fall out of that and all three are required:
31
+ *
32
+ * - the filename cannot leak what the file was called, or anything else about
33
+ * the person who chose it;
34
+ * - adding the same song twice stores it once, so duplicated items share a
35
+ * source instead of doubling the storage;
36
+ * - a partially written copy can never be mistaken for a complete one, because
37
+ * its name is only assigned after the last byte is hashed.
38
+ *
39
+ * ## Written whole or not at all
40
+ *
41
+ * Bytes go to `<random>.part`, are flushed and fsynced, and only then renamed
42
+ * to `<sha256>.bin`. A kill mid-copy leaves a `.part` that no project can refer
43
+ * to and that [sweep] removes. There is no window in which a truncated file
44
+ * carries a name something else is pointing at.
45
+ *
46
+ * ## Deletion is never implicit
47
+ *
48
+ * Nothing here is reference-counted, and Undo removes no files. An Undo that
49
+ * deleted the source would make Redo restore an item pointing at nothing —
50
+ * the edit history would be intact and the media gone. Sources are removed only
51
+ * by an explicit [sweep] against the set of tokens a project still names.
52
+ */
53
+ object ManagedSources {
54
+
55
+ private const val DIR = "pictor-sources"
56
+ private const val SUFFIX = ".bin"
57
+ private const val PART_SUFFIX = ".part"
58
+
59
+ /** Refuse absurd inputs rather than filling the user's storage. */
60
+ private const val MAX_BYTES = 512L * 1024L * 1024L
61
+
62
+ private fun dir(context: Context): File {
63
+ // `filesDir`, not `cacheDir`. The whole point is surviving the cleanup
64
+ // that cache exists to permit.
65
+ val d = File(context.filesDir, DIR)
66
+ if (!d.exists()) d.mkdirs()
67
+ return d
68
+ }
69
+
70
+ /**
71
+ * Copy [uri] into app-owned storage and return where it went.
72
+ *
73
+ * `{ok, uri, token, byteLength, sha256, created}` or `{ok:false, reason}`.
74
+ * `created` is false when the bytes were already stored — the caller must
75
+ * not release a source it did not bring into existence, because something
76
+ * else is using it.
77
+ */
78
+ fun ingest(context: Context, uri: String): JSONObject {
79
+ val out = JSONObject()
80
+ val parsed =
81
+ try {
82
+ Uri.parse(uri)
83
+ } catch (e: Exception) {
84
+ return out.put("ok", false).put("reason", "unparseable uri")
85
+ }
86
+
87
+ val directory = dir(context)
88
+ // The temporary name is random and carries nothing: it exists only long
89
+ // enough to be renamed to the digest of what it holds.
90
+ val part = File(directory, "ingest-${System.nanoTime()}-${(0..0xFFFFFF).random()}$PART_SUFFIX")
91
+
92
+ var total = 0L
93
+ val digest = MessageDigest.getInstance("SHA-256")
94
+
95
+ try {
96
+ val input =
97
+ context.contentResolver.openInputStream(parsed)
98
+ ?: return out.put("ok", false).put("reason", "source could not be opened")
99
+
100
+ input.use { stream ->
101
+ FileOutputStream(part).use { output ->
102
+ val buffer = ByteArray(256 * 1024)
103
+ while (true) {
104
+ val read = stream.read(buffer)
105
+ if (read <= 0) break
106
+ total += read
107
+ if (total > MAX_BYTES) {
108
+ throw IllegalStateException("source exceeds $MAX_BYTES bytes")
109
+ }
110
+ digest.update(buffer, 0, read)
111
+ output.write(buffer, 0, read)
112
+ }
113
+ output.flush()
114
+ // Durable before the rename, so the name never appears over
115
+ // bytes the platform has not committed.
116
+ output.fd.sync()
117
+ }
118
+ }
119
+
120
+ if (total <= 0L) {
121
+ part.delete()
122
+ return out.put("ok", false).put("reason", "source was empty")
123
+ }
124
+
125
+ val hex = digest.digest().joinToString("") { "%02x".format(it) }
126
+ val target = File(directory, hex + SUFFIX)
127
+
128
+ if (target.exists() && target.length() == total) {
129
+ /*
130
+ Already stored. The copy just made is redundant, and the
131
+ existing one is what everything else already points at, so
132
+ the new bytes go and the old name is returned.
133
+ */
134
+ part.delete()
135
+ return out
136
+ .put("ok", true)
137
+ .put("uri", Uri.fromFile(target).toString())
138
+ .put("token", hex)
139
+ .put("byteLength", target.length())
140
+ .put("sha256", hex)
141
+ .put("created", false)
142
+ }
143
+
144
+ if (!part.renameTo(target)) {
145
+ part.delete()
146
+ return out.put("ok", false).put("reason", "could not commit the copy")
147
+ }
148
+
149
+ return out
150
+ .put("ok", true)
151
+ .put("uri", Uri.fromFile(target).toString())
152
+ .put("token", hex)
153
+ .put("byteLength", total)
154
+ .put("sha256", hex)
155
+ .put("created", true)
156
+ } catch (e: Exception) {
157
+ // Every failure path takes its partial with it. A `.part` left
158
+ // behind is storage nobody can account for.
159
+ try {
160
+ part.delete()
161
+ } catch (ignored: Exception) {
162
+ // Reporting the delete over the failure that caused it would
163
+ // bury the useful half of the message.
164
+ }
165
+ return out.put("ok", false).put("reason", e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
166
+ }
167
+ }
168
+
169
+ /** Drop one managed source. Returns whether a file went away. */
170
+ fun release(context: Context, token: String): Boolean {
171
+ if (!safeToken(token)) return false
172
+ val file = File(dir(context), token + SUFFIX)
173
+ return file.exists() && file.delete()
174
+ }
175
+
176
+ /**
177
+ * Remove every managed source whose token is not in [keep], and every
178
+ * `.part` left by a killed process.
179
+ *
180
+ * The keep list is the project's, so this is the only thing that deletes
181
+ * media and it is always given the full set of what is still referenced.
182
+ */
183
+ fun sweep(context: Context, keep: JSONArray): JSONObject {
184
+ val keepSet = HashSet<String>(keep.length())
185
+ for (i in 0 until keep.length()) keepSet.add(keep.optString(i))
186
+
187
+ var removed = 0
188
+ var kept = 0
189
+ var partials = 0
190
+ var skipped = 0
191
+ val files = dir(context).listFiles() ?: emptyArray()
192
+ for (file in files) {
193
+ val name = file.name
194
+ when {
195
+ name.endsWith(PART_SUFFIX) -> {
196
+ // Nothing can be pointing at one: names are only assigned
197
+ // after the last byte is written.
198
+ if (file.delete()) partials += 1
199
+ }
200
+ name.endsWith(SUFFIX) && safeToken(name.removeSuffix(SUFFIX)) -> {
201
+ /*
202
+ Ours only if the stem is a real token.
203
+
204
+ The suffix alone is not identification. A gate seeded
205
+ `zzz-not-a-hash.bin` and this deleted it, because the
206
+ rule was "ends in .bin and is not in the keep list" —
207
+ which is every `.bin` anyone else ever puts here. The
208
+ directory being ours is not a licence to delete files we
209
+ did not write; a managed source is one whose name we
210
+ could have produced, and nothing else.
211
+ */
212
+ val token = name.removeSuffix(SUFFIX)
213
+ if (keepSet.contains(token)) kept += 1 else if (file.delete()) removed += 1
214
+ }
215
+ else -> {
216
+ /*
217
+ Not ours, and therefore not ours to delete.
218
+
219
+ Counted rather than ignored so the accounting adds up to
220
+ what the directory actually holds: a sweep that reported
221
+ only what it touched could not be checked against a
222
+ listing, and "I deleted nothing unexpected" is precisely
223
+ the claim worth checking.
224
+ */
225
+ skipped += 1
226
+ }
227
+ }
228
+ }
229
+ return JSONObject()
230
+ .put("ok", true)
231
+ .put("removed", removed)
232
+ .put("kept", kept)
233
+ .put("partials", partials)
234
+ .put("skipped", skipped)
235
+ }
236
+
237
+ /** What is stored, for assertions. Tokens and sizes, never names. */
238
+ fun ledger(context: Context): JSONObject {
239
+ val items = JSONArray()
240
+ var bytes = 0L
241
+ val files = dir(context).listFiles() ?: emptyArray()
242
+ for (file in files) {
243
+ if (!file.name.endsWith(SUFFIX)) continue
244
+ bytes += file.length()
245
+ items.put(
246
+ JSONObject()
247
+ .put("token", file.name.removeSuffix(SUFFIX))
248
+ .put("byteLength", file.length()),
249
+ )
250
+ }
251
+ return JSONObject().put("ok", true).put("count", items.length()).put("bytes", bytes).put("items", items)
252
+ }
253
+
254
+ /**
255
+ * What is actually in the directory, name by name.
256
+ *
257
+ * Read-only accounting, so it stays available in release for the same
258
+ * reason the ledgers do: it is how a sweep is *proven* rather than
259
+ * asserted. [ledger] reports only well-formed sources, which is exactly the
260
+ * wrong instrument for checking what a sweep left behind — a partial or a
261
+ * stray file is invisible to it.
262
+ */
263
+ fun list(context: Context): JSONObject {
264
+ val items = JSONArray()
265
+ val files = dir(context).listFiles() ?: emptyArray()
266
+ for (file in files.sortedBy { it.name }) {
267
+ items.put(JSONObject().put("name", file.name).put("byteLength", file.length()))
268
+ }
269
+ return JSONObject().put("ok", true).put("count", items.length()).put("items", items)
270
+ }
271
+
272
+ /**
273
+ * Gate-only: sweep with a keep list exactly as given, unparsed.
274
+ *
275
+ * [sweep] takes a `JSONArray`, so the module builds one and an unparseable
276
+ * keep list never reaches it. That is the right shape for production and
277
+ * the wrong one for proving the claim that matters — that a keep list which
278
+ * cannot be read deletes nothing. This lets a gate hand over the raw text.
279
+ */
280
+ fun gateSweepRaw(context: Context, raw: String): JSONObject {
281
+ if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
282
+ return try {
283
+ sweep(context, JSONArray(raw))
284
+ } catch (e: Exception) {
285
+ // The production path returns exactly this, and returns it *before*
286
+ // reaching `sweep`, so nothing is enumerated and nothing deleted.
287
+ JSONObject().put("ok", false).put("reason", "unparseable keep list")
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Gate-only: put a file in the directory with an exact name.
293
+ *
294
+ * The sweep's enumeration and matching cannot be exercised through the
295
+ * normal API — ingestion only ever produces well-formed, referenced
296
+ * sources, so the cases that matter (an orphan, a stale `.part`, a file
297
+ * that is not ours at all) are unreachable without this.
298
+ *
299
+ * Mutating, so [Diagnostics.ENABLED] guards it and a release build refuses
300
+ * at the first line. The name is rejected if it contains a separator or a
301
+ * traversal segment: a seeding helper that could write outside its own
302
+ * directory would be a worse hole than the one it exists to test.
303
+ */
304
+ fun gateSeed(context: Context, name: String, bytes: Int): JSONObject {
305
+ if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
306
+ if (name.isEmpty() || name.contains('/') || name.contains('\\') || name == "." || name == "..") {
307
+ return JSONObject().put("ok", false).put("reason", "unsafe name")
308
+ }
309
+ return try {
310
+ val file = File(dir(context), name)
311
+ // Deterministic content, so a later read can prove the bytes are the
312
+ // ones written rather than merely the right length.
313
+ val payload = ByteArray(bytes.coerceIn(0, 1 shl 20)) { (it % 251).toByte() }
314
+ FileOutputStream(file).use {
315
+ it.write(payload)
316
+ it.flush()
317
+ it.fd.sync()
318
+ }
319
+ JSONObject().put("ok", true).put("name", name).put("byteLength", file.length())
320
+ } catch (e: Exception) {
321
+ JSONObject().put("ok", false).put("reason", e.javaClass.simpleName)
322
+ }
323
+ }
324
+
325
+ /**
326
+ * A token is 64 lowercase hex characters and nothing else.
327
+ *
328
+ * Checked before it reaches a path so that a token from anywhere cannot
329
+ * name a file outside the directory.
330
+ */
331
+ private fun safeToken(token: String): Boolean =
332
+ token.length == 64 && token.all { it in '0'..'9' || it in 'a'..'f' }
333
+ }