@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,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
|
+
}
|