@pictor-sdk/video-android 0.1.0 → 0.1.3

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.
@@ -0,0 +1,405 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.HandlerThread
6
+ import android.util.Log
7
+ import androidx.media3.common.C
8
+ import androidx.media3.common.Effect
9
+ import androidx.media3.common.MediaItem
10
+ import androidx.media3.common.audio.SpeedProvider
11
+ import androidx.media3.common.util.UnstableApi
12
+ import androidx.media3.transformer.Composition
13
+ import androidx.media3.transformer.EditedMediaItem
14
+ import androidx.media3.transformer.EditedMediaItemSequence
15
+ import androidx.media3.transformer.ExportException
16
+ import androidx.media3.transformer.ExportResult
17
+ import androidx.media3.transformer.Effects
18
+ import androidx.media3.transformer.Transformer
19
+ import com.google.common.collect.ImmutableList
20
+ import java.io.File
21
+ import java.util.concurrent.CountDownLatch
22
+ import java.util.concurrent.TimeUnit
23
+ import org.json.JSONArray
24
+ import org.json.JSONObject
25
+
26
+ /**
27
+ * The smallest Media3 export that fails, found one stage at a time.
28
+ *
29
+ * ## Why this is not `ExportGate`
30
+ *
31
+ * `ExportGate.runSequence` can already be told to add or omit each stage, and
32
+ * using it would have been less code. It would also have kept every one of the
33
+ * things this is trying to rule out: the run registry, the dedicated
34
+ * Transformer looper, the heartbeat, the resource ledger, the stall watchdog,
35
+ * the channel mixer, the forced audio track, the temporary-file bookkeeping and
36
+ * the concatenation that follows. A reproduction that carries the suspect along
37
+ * with it is not a reproduction.
38
+ *
39
+ * So rung 1 here is a plain `Transformer` over one clipped `MediaItem` with no
40
+ * effects and no speed — about twenty lines of Media3, and nothing of Pictor's.
41
+ * Each later rung adds exactly one stage to that, and every rung runs
42
+ * independently against its own uniquely named output. The first rung that does
43
+ * not complete is the answer.
44
+ *
45
+ * ## Rates and spans are given, never derived
46
+ *
47
+ * A slider label is not a rate. The caller passes the configured speed, the
48
+ * source span in microseconds, and — for the cadence rungs — the emission plan
49
+ * `resampleClip` compiled in TypeScript, so what runs here is what production
50
+ * would have run rather than a second opinion about it.
51
+ *
52
+ * Diagnostics-only, like the rest of the gate family: the entry point refuses
53
+ * on its first statement without [Diagnostics.ENABLED].
54
+ */
55
+ @UnstableApi
56
+ internal object GateLadder {
57
+
58
+ /** What one rung is allowed to switch on. Nothing else varies between them. */
59
+ private class Rung(
60
+ val name: String,
61
+ val speed: Boolean,
62
+ val cadence: Boolean,
63
+ val skia: Boolean,
64
+ /**
65
+ * The audio processors `ExportGate` puts on every shard.
66
+ *
67
+ * A channel mixer, so shards can be concatenated by copying samples,
68
+ * and Sonic with the reciprocal pitch, so a rate change does not turn a
69
+ * voice into a tape effect. Neither is about the picture, and both sit
70
+ * on the audio path a sped-up item is re-timed through — which is the
71
+ * one part of a production shard the rungs above still do not have.
72
+ */
73
+ val audioProcessors: String = "none",
74
+ )
75
+
76
+ private val RUNGS = listOf(
77
+ // The floor: is a plain transcode of this span sound at all?
78
+ Rung("plain", speed = false, cadence = false, skia = false),
79
+ // One stage each, against that floor.
80
+ Rung("speed", speed = true, cadence = false, skia = false),
81
+ Rung("cadence", speed = false, cadence = true, skia = false),
82
+ Rung("skia", speed = false, cadence = false, skia = true),
83
+ // Then the pairs, and finally what production actually builds.
84
+ Rung("speed+cadence", speed = true, cadence = true, skia = false),
85
+ Rung("speed+cadence+skia", speed = true, cadence = true, skia = true),
86
+ // Everything a production shard's Transformer is built with, and then
87
+ // each of the two audio processors on its own — because "both of them
88
+ // together" names a pair, not a cause.
89
+ Rung("production", speed = true, cadence = true, skia = true, audioProcessors = "both"),
90
+ Rung("prod-mixer", speed = true, cadence = true, skia = true, audioProcessors = "mixer"),
91
+ Rung("prod-sonic", speed = true, cadence = true, skia = true, audioProcessors = "sonic"),
92
+ )
93
+
94
+ /** A constant rate, exactly as `ExportGate` declares one. */
95
+ private class ConstantSpeed(private val speed: Float) : SpeedProvider {
96
+ override fun getSpeed(timeUs: Long): Float = speed
97
+ override fun getNextSpeedChangeTimeUs(timeUs: Long): Long = C.TIME_UNSET
98
+ }
99
+
100
+ /**
101
+ * Run the ladder and report every rung.
102
+ *
103
+ * `specJson` is `{ uri, startUs, endUs, speed, timeoutSeconds?,
104
+ * emitsPerInput?: number[], outputPtsUs?: number[], only?: string[] }`.
105
+ *
106
+ * Every rung runs even after one fails. "The first failing configuration"
107
+ * is a claim about an ordering, and stopping at the first failure would
108
+ * leave no evidence about whether the later ones fail for the same reason
109
+ * or a different one.
110
+ */
111
+ /** The one thread a ladder runs on, so two cannot overlap. */
112
+ private val worker = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
113
+ Thread(r, "pictor-ladder").also { it.isDaemon = true }
114
+ }
115
+
116
+ @Volatile
117
+ private var running = false
118
+
119
+ /**
120
+ * Start a ladder in the background and report the result to logcat.
121
+ *
122
+ * Not synchronous, because the caller is the JavaScript thread: six rungs
123
+ * with a bounded wait each is minutes of it, and a blocked JS thread is an
124
+ * app that cannot be observed while the thing under investigation runs. The
125
+ * result arrives as one `PictorLadder` line, which is also how it is read.
126
+ */
127
+ fun start(context: Context, specJson: String): String {
128
+ if (running) {
129
+ return JSONObject().put("ok", false).put("error", "a ladder is already running")
130
+ .toString()
131
+ }
132
+ running = true
133
+ worker.execute {
134
+ val out = try {
135
+ run(context, specJson)
136
+ } catch (e: Throwable) {
137
+ JSONObject().put("ok", false)
138
+ .put("error", e.javaClass.simpleName + ": " + e.message).toString()
139
+ } finally {
140
+ running = false
141
+ }
142
+ Log.i("PictorLadder", out)
143
+ }
144
+ return JSONObject().put("ok", true).put("started", true).toString()
145
+ }
146
+
147
+ fun run(context: Context, specJson: String): String {
148
+ val spec = try {
149
+ JSONObject(specJson)
150
+ } catch (e: Exception) {
151
+ return JSONObject().put("ok", false).put("error", "bad spec: " + e.message).toString()
152
+ }
153
+ val uri = spec.optString("uri")
154
+ if (uri.isEmpty()) {
155
+ return JSONObject().put("ok", false).put("error", "no uri").toString()
156
+ }
157
+ val startUs = spec.optLong("startUs", 0)
158
+ val endUs = spec.optLong("endUs", 0)
159
+ val speed = spec.optDouble("speed", 1.0).toFloat()
160
+ val timeoutSeconds = spec.optLong("timeoutSeconds", 90)
161
+ val emits = spec.optJSONArray("emitsPerInput")?.let { a ->
162
+ IntArray(a.length()) { a.getInt(it) }
163
+ }
164
+ val pts = spec.optJSONArray("outputPtsUs")?.let { a ->
165
+ LongArray(a.length()) { a.getLong(it) }
166
+ }
167
+ val only = spec.optJSONArray("only")?.let { a ->
168
+ (0 until a.length()).map { a.getString(it) }.toSet()
169
+ }
170
+ /*
171
+ The two settings a shard is built with, and the reason they are here.
172
+
173
+ `ExportGate` forces an audio track onto every shard and drops the
174
+ source's own, because Media3 drives its speed path through audio and a
175
+ video-only item at any rate but 1 aborts with "no output sample
176
+ written". A ladder that omitted them would reproduce *that* known
177
+ failure and call it the stall. Both default to off — the minimal run
178
+ is the one with nothing added — and a second run turns them on, so the
179
+ difference between them is a measurement rather than an assumption.
180
+ */
181
+ val forceAudioTrack = spec.optBoolean("forceAudioTrack", false)
182
+ val removeAudio = spec.optBoolean("removeAudio", false)
183
+
184
+ val results = JSONArray()
185
+ var firstFailure: String? = null
186
+ for (rung in RUNGS) {
187
+ if (only != null && !only.contains(rung.name)) continue
188
+ if (rung.cadence && (emits == null || pts == null)) {
189
+ results.put(
190
+ JSONObject().put("rung", rung.name).put("ok", false)
191
+ .put("error", "no emission plan supplied"),
192
+ )
193
+ continue
194
+ }
195
+ val one = runRung(
196
+ context, rung, uri, startUs, endUs, speed, emits, pts, timeoutSeconds,
197
+ forceAudioTrack, removeAudio,
198
+ )
199
+ // Per rung, not only at the end: a ladder killed part-way through
200
+ // still has to have said what it learned before it was.
201
+ Log.i("PictorLadderRung", one.toString())
202
+ results.put(one)
203
+ if (!one.optBoolean("ok") && firstFailure == null) firstFailure = rung.name
204
+ }
205
+ return JSONObject()
206
+ .put("ok", firstFailure == null)
207
+ .put("speed", speed.toDouble())
208
+ .put("startUs", startUs)
209
+ .put("endUs", endUs)
210
+ .put("firstFailure", firstFailure ?: "")
211
+ .put("forceAudioTrack", forceAudioTrack)
212
+ .put("removeAudio", removeAudio)
213
+ .put("rungs", results)
214
+ .toString()
215
+ }
216
+
217
+ private fun runRung(
218
+ context: Context,
219
+ rung: Rung,
220
+ uri: String,
221
+ startUs: Long,
222
+ endUs: Long,
223
+ speed: Float,
224
+ emits: IntArray?,
225
+ pts: LongArray?,
226
+ timeoutSeconds: Long,
227
+ forceAudioTrack: Boolean,
228
+ removeAudio: Boolean,
229
+ ): JSONObject {
230
+ /*
231
+ A fresh looper per rung, and joined before returning.
232
+
233
+ Not for tidiness: a rung that times out leaves a Transformer holding
234
+ codecs on the thread that built it, and reusing one looper would let
235
+ the next rung's failure be the previous rung's leftovers. Each rung
236
+ gets a thread that is only ever used for it, and whether that thread
237
+ was still alive at the end is reported rather than assumed.
238
+ */
239
+ val thread = HandlerThread("pictor-ladder-" + rung.name.replace('+', '-'))
240
+ thread.start()
241
+ val handler = Handler(thread.looper)
242
+
243
+ /*
244
+ The app's external files directory, not the cache.
245
+
246
+ A rung's output has to be read back off the device, and the build this
247
+ runs in is a release one — not debuggable, so `run-as` cannot reach
248
+ internal storage. The external files directory needs no permission,
249
+ nothing scans it, and `adb pull` can read it. Unique by construction,
250
+ so an older file can never be read as this run's result.
251
+ */
252
+ val dir = File(context.getExternalFilesDir(null) ?: context.cacheDir, "pictor-ladder")
253
+ dir.mkdirs()
254
+ val output = File.createTempFile(
255
+ "ladder-" + rung.name.replace('+', '-') + "-" + System.nanoTime() + "-", ".mp4", dir,
256
+ )
257
+
258
+ val latch = CountDownLatch(1)
259
+ var result: ExportResult? = null
260
+ var failure: ExportException? = null
261
+ var buildError: String? = null
262
+ val cadenceEffect = if (rung.cadence && emits != null && pts != null) {
263
+ CadenceEffect(emits, pts)
264
+ } else {
265
+ null
266
+ }
267
+ val skiaEffect = if (rung.skia) SkiaPassThroughEffect() else null
268
+
269
+ val startedNs = System.nanoTime()
270
+ var transformer: Transformer? = null
271
+ handler.post {
272
+ try {
273
+ val media = MediaItem.Builder()
274
+ .setUri(uri)
275
+ .setClippingConfiguration(
276
+ MediaItem.ClippingConfiguration.Builder()
277
+ .setStartPositionUs(startUs)
278
+ .setEndPositionUs(endUs)
279
+ .build(),
280
+ )
281
+ .build()
282
+ val video = ArrayList<Effect>()
283
+ cadenceEffect?.let { video.add(it) }
284
+ skiaEffect?.let { video.add(it as Effect) }
285
+ val audio = ArrayList<androidx.media3.common.audio.AudioProcessor>()
286
+ if (rung.audioProcessors == "both" || rung.audioProcessors == "mixer") {
287
+ val mixer = androidx.media3.common.audio.ChannelMixingAudioProcessor()
288
+ for (inChannels in 1..2) {
289
+ mixer.putChannelMixingMatrix(
290
+ androidx.media3.common.audio.ChannelMixingMatrix
291
+ .createForConstantPower(inChannels, 2),
292
+ )
293
+ }
294
+ audio.add(mixer)
295
+ }
296
+ if ((rung.audioProcessors == "both" || rung.audioProcessors == "sonic") &&
297
+ rung.speed
298
+ ) {
299
+ // The reciprocal, exactly as `ExportGate` composes it: the
300
+ // resampling moved tempo and pitch together, and this puts
301
+ // the pitch back.
302
+ val sonic = androidx.media3.common.audio.SonicAudioProcessor()
303
+ sonic.setPitch(1f / speed)
304
+ audio.add(sonic)
305
+ }
306
+ val edited = EditedMediaItem.Builder(media)
307
+ .also { if (removeAudio) it.setRemoveAudio(true) }
308
+ .also { if (rung.speed) it.setSpeed(ConstantSpeed(speed)) }
309
+ .setEffects(
310
+ Effects(ImmutableList.copyOf(audio), ImmutableList.copyOf(video)),
311
+ )
312
+ .build()
313
+ val sequence = EditedMediaItemSequence.Builder()
314
+ .also { if (forceAudioTrack) it.experimentalSetForceAudioTrack(true) }
315
+ .addItem(edited)
316
+ .build()
317
+ val composition = Composition.Builder(sequence).build()
318
+ val built = Transformer.Builder(context)
319
+ .addListener(object : Transformer.Listener {
320
+ override fun onCompleted(c: Composition, r: ExportResult) {
321
+ result = r
322
+ latch.countDown()
323
+ }
324
+
325
+ override fun onError(c: Composition, r: ExportResult, e: ExportException) {
326
+ failure = e
327
+ latch.countDown()
328
+ }
329
+ })
330
+ .build()
331
+ transformer = built
332
+ built.start(composition, output.absolutePath)
333
+ } catch (e: Throwable) {
334
+ buildError = e.javaClass.simpleName + ": " + e.message
335
+ latch.countDown()
336
+ }
337
+ }
338
+
339
+ val finished = latch.await(timeoutSeconds, TimeUnit.SECONDS)
340
+ val elapsedMs = (System.nanoTime() - startedNs) / 1_000_000
341
+
342
+ /*
343
+ A timed-out rung is cancelled, and the cancel is *not* waited on here.
344
+
345
+ `Transformer.cancel()` joins the internal threads and can itself block
346
+ indefinitely when the encoder never finishes — which is the condition
347
+ under investigation. Posting it and reporting whether the post was
348
+ answered distinguishes "cleaned up" from "still wedged" instead of
349
+ hanging the ladder on the fault it is measuring.
350
+ */
351
+ var cancelReturned = false
352
+ if (!finished) {
353
+ val cancelled = CountDownLatch(1)
354
+ handler.post {
355
+ try {
356
+ transformer?.cancel()
357
+ } catch (_: Throwable) {
358
+ }
359
+ cancelled.countDown()
360
+ }
361
+ cancelReturned = cancelled.await(10, TimeUnit.SECONDS)
362
+ }
363
+
364
+ val report = JSONObject()
365
+ .put("rung", rung.name)
366
+ .put("ok", finished && failure == null && buildError == null)
367
+ .put("elapsedMs", elapsedMs)
368
+ .put("timedOut", !finished)
369
+ .put("cancelReturned", cancelReturned)
370
+ .put(
371
+ "error",
372
+ buildError
373
+ ?: failure?.let { it.errorCode.toString() + ": " + (it.cause?.message ?: it.message) }
374
+ ?: if (!finished) "timeout after " + timeoutSeconds + "s" else "",
375
+ )
376
+ .put("videoFrameCount", result?.videoFrameCount ?: -1)
377
+ .put("durationMs", result?.durationMs ?: -1)
378
+ .put("videoEncoder", result?.videoEncoderName ?: "")
379
+ .put("audioEncoder", result?.audioEncoderName ?: "")
380
+ .put("outputBytes", if (output.exists()) output.length() else -1)
381
+ .put("output", output.absolutePath)
382
+ .put("cadenceTrace", cadenceEffect?.trace() ?: "")
383
+ .put("skiaFrames", skiaEffect?.framesSeen() ?: -1)
384
+
385
+ /*
386
+ Whether the rung's thread is still occupied is part of the result.
387
+
388
+ `quitSafely` returns immediately; the thread only ends once the looper
389
+ drains. A thread still alive a second later is one still inside
390
+ something — a blocked `cancel`, an encoder wait — and that is the
391
+ cleanup evidence a frame count cannot give.
392
+ */
393
+ thread.quitSafely()
394
+ thread.join(1_000)
395
+ report.put("threadStillAlive", thread.isAlive)
396
+ if (finished && failure == null && buildError == null) {
397
+ report.put("keptOutput", true)
398
+ } else {
399
+ // A failed rung's partial file is not a result; nothing may read it.
400
+ output.delete()
401
+ report.put("keptOutput", false)
402
+ }
403
+ return report
404
+ }
405
+ }
@@ -1,17 +1,26 @@
1
1
  package dev.pictor.video
2
2
 
3
+ import android.Manifest
3
4
  import android.content.ContentValues
4
5
  import android.content.Context
6
+ import android.content.pm.PackageManager
5
7
  import android.media.MediaExtractor
6
8
  import android.media.MediaFormat
9
+ import android.media.MediaScannerConnection
7
10
  import android.net.Uri
11
+ import android.os.Build
12
+ import android.os.Environment
8
13
  import android.provider.MediaStore
14
+ import androidx.annotation.RequiresApi
9
15
  import org.json.JSONObject
10
16
  import java.io.File
11
17
  import java.io.FileInputStream
18
+ import java.io.FileOutputStream
12
19
  import java.text.SimpleDateFormat
13
20
  import java.util.Date
14
21
  import java.util.Locale
22
+ import java.util.concurrent.CountDownLatch
23
+ import java.util.concurrent.TimeUnit
15
24
  import java.util.concurrent.atomic.AtomicBoolean
16
25
 
17
26
  /**
@@ -29,15 +38,23 @@ import java.util.concurrent.atomic.AtomicBoolean
29
38
  * Every unsuccessful path deletes the row. A pending row nobody deletes is
30
39
  * invisible and still occupies storage, which is the worst of both.
31
40
  *
32
- * ## There is no legacy branch, and that is a decision
41
+ * ## Two paths, because `IS_PENDING` arrived in API 29
33
42
  *
34
- * `IS_PENDING` and `RELATIVE_PATH` arrived in API 29, and this module's
35
- * `minSdk` is 29 — checked, not assumed. So the pre-scoped-storage path
36
- * (`WRITE_EXTERNAL_STORAGE`, a real path under `Environment`, a scanner poke)
37
- * is not a branch this can reach on any supported device. It is **rejected
38
- * rather than written**: an untestable branch guarding a version nobody runs is
39
- * a liability, and lowering `minSdk` to make it reachable would be changing the
40
- * module's support claim to justify code.
43
+ * On 29 and above the pending row above is the whole story. Below it there is no
44
+ * `IS_PENDING` and no `RELATIVE_PATH`, so the same guarantee is built out of the
45
+ * filesystem: the bytes stream into a **dot-prefixed** file, which the media
46
+ * scanner ignores, and only a completed, verified file is renamed into place and
47
+ * handed to the scanner. Nothing partial is ever visible on either path, which is
48
+ * the property that matters — the mechanism differs because the platform does.
49
+ *
50
+ * The legacy path needs `WRITE_EXTERNAL_STORAGE`, which is why this module's
51
+ * manifest declares it with `maxSdkVersion="28"`. It disappears on every device
52
+ * that has scoped storage, and the save reports a specific, actionable error
53
+ * rather than throwing when a host has not asked for it.
54
+ *
55
+ * This branch exists on paper until an API 24..28 device or emulator runs it.
56
+ * Until then `minSdk 24` is a *build* claim, not a verified one — see
57
+ * `docs/research/min-sdk-24.md`.
41
58
  *
42
59
  * ## What it will not do
43
60
  *
@@ -59,6 +76,19 @@ internal object MediaStoreSave {
59
76
  */
60
77
  private const val RELATIVE_PATH_STORED = "Movies/Pictor/"
61
78
 
79
+ /** The same folder as {@link RELATIVE_PATH}, as a name under `Movies`. */
80
+ private const val LEGACY_FOLDER = "Pictor"
81
+
82
+ /**
83
+ * What an unfinished legacy save is called.
84
+ *
85
+ * The leading dot is what hides it — the media scanner skips dot-prefixed
86
+ * files — and the suffix is what a sweep matches on. Both are needed: the dot
87
+ * keeps it out of the gallery, the suffix keeps the sweep from deleting
88
+ * something a person put there.
89
+ */
90
+ private const val LEGACY_PENDING_SUFFIX = ".part"
91
+
62
92
  /**
63
93
  * The names this writes, and only those.
64
94
  *
@@ -120,6 +150,9 @@ internal object MediaStoreSave {
120
150
  // Named, not pathed: the caller knows what it asked to save.
121
151
  return out.put("ok", false).put("error", "the export file is no longer available").toString()
122
152
  }
153
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
154
+ return saveLegacy(context, jobId, source, cancelled, out)
155
+ }
123
156
 
124
157
  val name = displayName()
125
158
  val values = ContentValues().apply {
@@ -199,6 +232,144 @@ internal object MediaStoreSave {
199
232
  }
200
233
  }
201
234
 
235
+ /**
236
+ * The same guarantee without `IS_PENDING`, for API 24..28.
237
+ *
238
+ * A dot-prefixed file is skipped by the media scanner, so it stands in for
239
+ * the pending row: the bytes stream into `.Pictor-….mp4.part`, the result is
240
+ * verified, and only then is it renamed to its real name and scanned. A
241
+ * half-written video is never in the gallery, and a failed or cancelled save
242
+ * leaves nothing behind — the same two properties the 29+ path has, built
243
+ * out of what the platform offered at the time.
244
+ *
245
+ * The order matters. Renaming before verifying would make a truncated file
246
+ * visible for as long as the check took, and scanning before renaming would
247
+ * index the temporary name.
248
+ */
249
+ private fun saveLegacy(
250
+ context: Context,
251
+ jobId: String,
252
+ source: File,
253
+ cancelled: AtomicBoolean,
254
+ out: JSONObject,
255
+ ): String {
256
+ /*
257
+ Asked for, not assumed.
258
+
259
+ Writing to shared storage below API 29 needs WRITE_EXTERNAL_STORAGE,
260
+ and a host that has not requested it gets a sentence it can act on
261
+ rather than a SecurityException from inside a copy loop. The adapter
262
+ declares the permission (capped at 28); asking the user for it is the
263
+ host's, because the host owns the moment to ask.
264
+ */
265
+ if (
266
+ context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
267
+ PackageManager.PERMISSION_GRANTED
268
+ ) {
269
+ forget(jobId)
270
+ return out
271
+ .put("ok", false)
272
+ .put("needsPermission", Manifest.permission.WRITE_EXTERNAL_STORAGE)
273
+ .put("error", "saving to the gallery on this Android version needs storage permission")
274
+ .toString()
275
+ }
276
+
277
+ val folder = File(
278
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
279
+ LEGACY_FOLDER,
280
+ )
281
+ if (!folder.exists() && !folder.mkdirs()) {
282
+ forget(jobId)
283
+ return out.put("ok", false).put("error", "the gallery folder could not be created").toString()
284
+ }
285
+
286
+ val name = displayName()
287
+ val pending = File(folder, ".$name$LEGACY_PENDING_SUFFIX")
288
+ val target = File(folder, name)
289
+
290
+ /** Nothing partial survives: the temporary, and the target if we got that far. */
291
+ fun abandon(reason: String): String {
292
+ runCatching { if (pending.exists()) pending.delete() }
293
+ runCatching { if (target.exists()) target.delete() }
294
+ forget(jobId)
295
+ return out
296
+ .put("ok", false)
297
+ .put("cancelled", reason == "cancelled")
298
+ .put("error", reason)
299
+ .toString()
300
+ }
301
+
302
+ try {
303
+ if (cancelled.get()) return abandon("cancelled")
304
+
305
+ var copied = 0L
306
+ FileOutputStream(pending).use { output ->
307
+ FileInputStream(source).use { input ->
308
+ val buffer = ByteArray(256 * 1024)
309
+ while (true) {
310
+ if (cancelled.get()) return abandon("cancelled")
311
+ val read = input.read(buffer)
312
+ if (read <= 0) break
313
+ output.write(buffer, 0, read)
314
+ copied += read
315
+ }
316
+ output.flush()
317
+ output.fd.sync()
318
+ }
319
+ }
320
+
321
+ if (copied != source.length()) {
322
+ // A full disk ends a stream without throwing, and the result
323
+ // would play as a truncated video.
324
+ return abandon("the file could not be written completely")
325
+ }
326
+ if (cancelled.get()) return abandon("cancelled")
327
+
328
+ val probe = probe(context, Uri.fromFile(pending))
329
+ if (!probe.optBoolean("readable", false)) {
330
+ return abandon("the saved file could not be read back")
331
+ }
332
+ if (cancelled.get()) return abandon("cancelled")
333
+
334
+ if (!pending.renameTo(target)) return abandon("the saved file could not be published")
335
+
336
+ /*
337
+ The scanner's uri, or the file's.
338
+
339
+ `scanFile` is asynchronous and the caller wants something to hand
340
+ back, so this waits briefly for the row. A scan that is slow is not
341
+ a failed save: the bytes are in place and the scanner will get to
342
+ them, so a timeout still reports success, with the `file:` uri.
343
+ */
344
+ val scanned = arrayOfNulls<Uri>(1)
345
+ val latch = CountDownLatch(1)
346
+ MediaScannerConnection.scanFile(
347
+ context,
348
+ arrayOf(target.absolutePath),
349
+ arrayOf(MIME),
350
+ ) { _, uri ->
351
+ scanned[0] = uri
352
+ latch.countDown()
353
+ }
354
+ runCatching { latch.await(5, TimeUnit.SECONDS) }
355
+
356
+ forget(jobId)
357
+ return out
358
+ .put("ok", true)
359
+ .put("uri", (scanned[0] ?: Uri.fromFile(target)).toString())
360
+ .put("displayName", name)
361
+ .put("location", RELATIVE_PATH)
362
+ .put("bytes", copied)
363
+ .put("legacy", true)
364
+ .put("durationUs", probe.optLong("durationUs"))
365
+ .put("hasVideo", probe.optBoolean("hasVideo"))
366
+ .put("hasAudio", probe.optBoolean("hasAudio"))
367
+ .toString()
368
+ } catch (e: Exception) {
369
+ return abandon(describe(e))
370
+ }
371
+ }
372
+
202
373
  /** Delete the pending row, then report. Never reached for a published one. */
203
374
  private fun cleanup(
204
375
  resolver: android.content.ContentResolver,
@@ -281,7 +452,43 @@ internal object MediaStoreSave {
281
452
  * platform behaviour rather than state it. A row in this folder, with this
282
453
  * name, that somebody else wrote is still not this app's to remove.
283
454
  */
284
- fun sweepPending(context: Context): String {
455
+ fun sweepPending(context: Context): String =
456
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) sweepLegacy()
457
+ else sweepPendingRows(context)
458
+
459
+ /**
460
+ * The legacy sweep: unfinished files, not rows.
461
+ *
462
+ * Same predicate in spirit as the row sweep — this app's folder, this code's
463
+ * name shape — because this deletes things. A file without the suffix is
464
+ * somebody's saved video and is never touched, including one that a person
465
+ * renamed into the folder themselves.
466
+ */
467
+ private fun sweepLegacy(): String {
468
+ val out = JSONObject()
469
+ val folder = File(
470
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
471
+ LEGACY_FOLDER,
472
+ )
473
+ var swept = 0
474
+ var inspected = 0
475
+ runCatching {
476
+ for (file in folder.listFiles().orEmpty()) {
477
+ inspected += 1
478
+ if (!file.name.startsWith(".") || !file.name.endsWith(LEGACY_PENDING_SUFFIX)) continue
479
+ if (file.delete()) swept += 1
480
+ }
481
+ }
482
+ return out
483
+ .put("ok", true)
484
+ .put("legacy", true)
485
+ .put("inspected", inspected)
486
+ .put("swept", swept)
487
+ .toString()
488
+ }
489
+
490
+ @RequiresApi(Build.VERSION_CODES.Q)
491
+ private fun sweepPendingRows(context: Context): String {
285
492
  val resolver = context.contentResolver
286
493
  val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
287
494
  var swept = 0