Clear the 35 findings the new tools reported

detekt found 29 and Android lint 6, on a codebase neither had ever seen. Each
one was either fixed or relaxed with the reason written next to it; nothing was
suppressed to make the build quiet.

Fixed, because the tool was right:

  - ConversionDependencies constructs Media3Engine, which is @UnstableApi, and
    was not marked. Every other type here that touches Media3 propagates the
    marker rather than swallowing it with @OptIn, so this one does too. Lint
    was the only thing that had ever noticed.
  - MediaProbe converted microseconds to milliseconds with a bare 1000, twice,
    in a file that also handles a seconds-based duration from a different API.
    US_PER_MS and MS_PER_SECOND now say which is which -- that confusion is a
    real bug source in media code, not a style question.
  - Foreground service types compared SDK_INT against 34 and 35 as raw ints
    while the doc comment above spelled the version names out. VERSION_CODES
    says it in the code.
  - take(3) is a product decision about how many alternatives an error offers.
    It means nothing until it is named; MAX_SUGGESTIONS does.
  - setProgress(100, ...) is a percentage max, now PERCENT_MAX.

Relaxed, because the rule did not fit:

  - The model package is excluded from ReturnCount and CyclomaticComplexMethod
    ONLY. It is the decision layer: ConversionRouter.route scores 17 because
    the app can give 17 distinct answers to "which engine, and why", each with
    its own user-visible reason, and route's own comment records that their
    ORDER decides which message is shown. Counting those as complexity measures
    how many answers exist, not how hard the code is to follow. Everything else
    -- LongMethod, NestedBlockDepth, ComplexCondition -- still applies there.
  - A flat `when` used as a lookup table scores a point per entry, so
    MediaProbe's demuxer-name to Container map read as complexity 21 with no
    nesting and no state. ignoreSingleWhenExpression is the rule's own answer.
  - TooGenericExceptionCaught off. MediaProbe, ConversionWorker and ConcatWorker
    sit in front of native code that reports a malformed file as anything from
    IllegalArgumentException to a bare RuntimeException, undocumented.
    Enumerating that list means guessing, and a wrong guess crashes the app on a
    file it could have reported as unreadable. SwallowedException stays on, so
    these still have to log and handle.
  - SI thresholds in the byte formatter, via ignoreNumbers. Each literal sits on
    the line with the unit string it belongs to; BYTES_PER_MB would need a Long
    and a Double and say nothing the line does not.
  - allowedFunctionsPerObject, which the first pass simply missed.

Lint's three version-freshness nags are off. They do not describe this code --
they go red the day someone else publishes a release, which turns a PR red for
something its author cannot see in their diff, and they want the network at
lint time. Upgrades here are deliberate; Kotlin in particular is pinned to AGP's
bundled KGP and is not free to follow the newest release.

UsableSpace is informational rather than disabled, because it is a real finding
that this commit is choosing not to act on. hasSpaceFor reads File.usableSpace,
which ignores reclaimable cache, so the app can refuse a conversion it had room
for. StorageManager.getAllocatableBytes is the better answer, but it changes
when a job is rejected and can throw -- a behaviour change to a safety check,
which deserves its own commit and its own test rather than a drive-by here.
informational keeps it in every lint report instead of hiding it.

Also adds the CI gate and a CLAUDE.md. Coverage is reported and not gated: the
measured baseline is 31% of lines, which is exactly why LibreMail's 0.84 floor
was evidence about LibreMail and not a number to copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:21:15 -05:00
co-authored by Claude Opus 5
parent 496f1c7e73
commit 65a94b4ec1
9 changed files with 251 additions and 7 deletions
@@ -115,7 +115,7 @@ object MediaProbe {
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
Extracted(video, audio, durationUs / 1000, width, height)
Extracted(video, audio, durationUs / US_PER_MS, width, height)
} catch (e: Exception) {
Log.i(TAG, "Platform extractor could not read $uri.", e)
null
@@ -162,7 +162,7 @@ object MediaProbe {
container = containerFrom(formatName, video?.getCodec()),
videoCodec = video?.getCodec(),
audioCodec = audio?.getCodec(),
durationMs = info.getDuration()?.toDoubleOrNull()?.times(1000)?.toLong() ?: 0L,
durationMs = info.getDuration()?.toDoubleOrNull()?.times(MS_PER_SECOND)?.toLong() ?: 0L,
width = video?.getWidth()?.toInt() ?: 0,
height = video?.getHeight()?.toInt() ?: 0,
isImage = isImageFormat(formatName),
@@ -284,4 +284,10 @@ object MediaProbe {
}
private const val TAG = "MediaProbe"
/** MediaExtractor reports KEY_DURATION in microseconds; InputProbe carries milliseconds. */
private const val US_PER_MS = 1000
/** MediaMetadataRetriever's ffprobe-style duration is in seconds, as a decimal string. */
private const val MS_PER_SECOND = 1000
}
@@ -2,6 +2,7 @@ package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConversionRequest
@@ -58,6 +59,7 @@ interface SoftwareTranscoder {
* with a content URI pointing at a provider that does not exist, or by constructing a
* worker's input Data by hand rather than through its request() helper.
*/
@UnstableApi
object ConversionDependencies {
@Volatile
@@ -261,9 +261,15 @@ object ContainerCapabilities {
.filter { it != exclude }
.distinct()
.filter { validate(it, probe).isValid }
.take(3)
.take(MAX_SUGGESTIONS)
}
/**
* How many alternatives an [Validation.Invalid] offers. Enough to show a real choice,
* few enough that the error stays readable.
*/
private const val MAX_SUGGESTIONS = 3
/** Best valid spec for this container, preserving as much of the request as possible. */
private fun repair(spec: OutputSpec, probe: InputProbe): OutputSpec? {
val container = spec.container
@@ -30,8 +30,15 @@ object ConversionForegroundType {
* `mediaProcessing` constant does not exist to pass in the first place.
*/
fun current(): Int = when {
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> 0
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM ->
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ->
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> NO_TYPE_REQUIRED
}
/** API 33 wants no type at all, and `ForegroundInfo` reads 0 as exactly that. */
private const val NO_TYPE_REQUIRED = 0
}
@@ -42,7 +42,7 @@ class ConversionNotifications(private val context: Context) {
// Progress updates far outpace what the UI can use; alerting once keeps
// the system UI from being hammered.
.setOnlyAlertOnce(true)
.setProgress(100, percent, indeterminate)
.setProgress(PERCENT_MAX, percent, indeterminate)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.action_cancel),
@@ -64,5 +64,8 @@ class ConversionNotifications(private val context: Context) {
companion object {
const val CHANNEL_ID = "conversions"
private const val TAG = "ConversionNotifications"
/** `setProgress` takes a max and a current; progress is reported as a percentage. */
private const val PERCENT_MAX = 100
}
}