build.gradle.kts 311 lignes · 13697 octets
import java.net.URI
import java.security.MessageDigest
import java.util.Properties
import java.util.zip.ZipInputStream

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose")
    id("org.jetbrains.kotlin.plugin.serialization")
    id("com.google.devtools.ksp")
}

/** `fast` (défaut, ~18 Mo) ou `full` (~80 Mo+). Ex. : ./gradlew assembleDebug -Ptessdata=full */
val tessdataVariant: String =
    (findProperty("tessdata") as String?)?.lowercase()?.trim().orEmpty().ifEmpty { "fast" }
require(tessdataVariant == "fast" || tessdataVariant == "full") {
    "Propriété -Ptessdata= invalide (« $tessdataVariant ») — utiliser fast ou full"
}

val tessdataLanguages = listOf("fra", "deu", "eng", "spa", "por", "ita", "pol")
val tessdataCacheDir = rootProject.layout.projectDirectory.dir("tessdata-cache/$tessdataVariant")
val tessdataGeneratedAssets = layout.buildDirectory.dir("generated/tessdataAssets")

/** `standard` (défaut, ~41 Mo) ou `none` (désactive le modèle). Ex. : ./gradlew assembleDebug -Pvosk=none */
val voskVariante: String =
    (findProperty("vosk") as String?)?.lowercase()?.trim().orEmpty().ifEmpty { "standard" }
require(voskVariante == "standard" || voskVariante == "none") {
    "Propriété -Pvosk= invalide (« $voskVariante ») — utiliser standard ou none"
}

val VOSK_MODEL_NOM = "vosk-model-small-fr-0.22"
val voskCacheDir = rootProject.layout.projectDirectory.dir("vosk-cache")
val voskGeneratedAssets = layout.buildDirectory.dir("generated/voskAssets")

/** Signature release : lue depuis android/keystore.properties (hors git).
 *  Absente → assembleRelease produit un APK non signé, comme avant. */
val keystoreProperties = Properties().apply {
    val fichier = rootProject.file("keystore.properties")
    if (fichier.isFile) fichier.inputStream().use { load(it) }
}

android {
    namespace = "fr.ebii.card2vcf"
    compileSdk = 36
    defaultConfig {
        applicationId = "fr.ebii.piclead"
        minSdk = 30
        targetSdk = 36
        versionCode = 5
        versionName = "0.5.1"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        ndk {
            abiFilters += listOf("arm64-v8a")
        }
    }
    buildFeatures {
        compose = true
        buildConfig = true
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_21
        targetCompatibility = JavaVersion.VERSION_21
    }
    kotlinOptions { jvmTarget = "21" }
    testOptions {
        unitTests.isReturnDefaultValues = true
        unitTests.isIncludeAndroidResources = true
    }
    /** `fdroid` : aucun modèle embarqué, l'utilisateur les télécharge depuis l'app.
     *  `direct` : modèles dans le APK, comme historiquement (bêta-testeurs). */
    flavorDimensions += "distribution"
    productFlavors {
        create("fdroid")
        create("direct")
    }
    sourceSets.getByName("direct").assets.srcDir(tessdataGeneratedAssets)
    sourceSets.getByName("direct").assets.srcDir(voskGeneratedAssets)
    signingConfigs {
        if (keystoreProperties.getProperty("storeFile") != null) {
            create("release") {
                storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
                storePassword = keystoreProperties.getProperty("storePassword")
                keyAlias = keystoreProperties.getProperty("keyAlias")
                keyPassword = keystoreProperties.getProperty("keyPassword")
            }
        }
    }
    buildTypes {
        release {
            isMinifyEnabled = false
            signingConfig = signingConfigs.findByName("release")
        }
    }
    // Sorties nommées d'après l'application : PicLead-<version>-<saveur>-<type>.apk
    applicationVariants.all {
        val variant = this
        outputs.all {
            (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName =
                "PicLead-${variant.versionName}-${variant.flavorName}-${variant.buildType.name}.apk"
        }
    }
}

fun fileSha256(file: File): String =
    MessageDigest.getInstance("SHA-256").digest(file.readBytes())
        .joinToString("") { "%02x".format(it) }

/** SHA-1 « git blob » — l'empreinte qu'expose l'API GitHub pour un fichier. */
fun gitBlobSha1(file: File): String {
    val md = MessageDigest.getInstance("SHA-1")
    md.update("blob ${file.length()}\u0000".toByteArray())
    md.update(file.readBytes())
    return md.digest().joinToString("") { "%02x".format(it) }
}

val prepareTessdata by tasks.registering {
    group = "build"
    description = "Télécharge (cache) et prépare les traineddata Tessdata ($tessdataVariant)"
    inputs.property("variant", tessdataVariant)
    inputs.property("languages", tessdataLanguages.joinToString(","))
    outputs.dir(tessdataGeneratedAssets)
    doLast {
        val cache = tessdataCacheDir.asFile.apply { mkdirs() }
        val outDir = tessdataGeneratedAssets.get().asFile
            .resolve("tesseract/tessdata")
            .apply { mkdirs() }
        val repo = if (tessdataVariant == "fast") "tessdata_fast" else "tessdata"
        for (lang in tessdataLanguages) {
            val cached = cache.resolve("$lang.traineddata")
            if (!cached.isFile || cached.length() < 10_000L) {
                val url = "https://github.com/tesseract-ocr/$repo/raw/main/$lang.traineddata"
                logger.lifecycle("Téléchargement $repo/$lang.traineddata …")
                URI(url).toURL().openStream().use { input ->
                    cached.outputStream().use { output -> input.copyTo(output) }
                }
                check(cached.isFile && cached.length() >= 10_000L) {
                    "Échec téléchargement $url"
                }
            }
            cached.copyTo(outDir.resolve("$lang.traineddata"), overwrite = true)
        }
        outDir.resolve("variant.txt").writeText(tessdataVariant)
        outDir.resolve("model-info.txt").writeText(
            tessdataLanguages.joinToString("\n") { lang ->
                val f = outDir.resolve("$lang.traineddata")
                "$lang ${f.length()} sha256=${fileSha256(f)}"
            } + "\n"
        )
        outDir.resolve("README.md").writeText(
            "Généré au build (variante=$tessdataVariant). Ne pas éditer.\n" +
                "Cache : android/tessdata-cache/$tessdataVariant/\n"
        )
        logger.lifecycle("Tessdata $tessdataVariant prêt (${tessdataLanguages.size} langues).")
    }
}

// Rattaché aux seules variantes `direct` : la saveur `fdroid` ne doit pas payer
// le téléchargement de 172 Mo de modèles qu'elle n'embarquera pas.
tasks.matching {
    it.name.startsWith("merge") && it.name.endsWith("Assets") && it.name.contains("Direct")
}.configureEach {
    dependsOn(prepareTessdata)
}
// `pre<Saveur><Type>Build` ordonne aussi les tâches lint, qui lisent les assets générés :
// sans cela Gradle refuse le build pour dépendance implicite non déclarée.
tasks.matching {
    it.name.startsWith("pre") && it.name.endsWith("Build") && it.name.contains("Direct")
}.configureEach {
    dependsOn(prepareTessdata)
}

/** Compare le cache local des traineddata avec l'upstream GitHub (lecture seule).
 *  Usage : ./gradlew tessdataStatus [-Ptessdata=full] */
val tessdataStatus by tasks.registering {
    group = "verification"
    description = "Empreintes des traineddata en cache ($tessdataVariant) vs upstream GitHub"
    doLast {
        val repo = if (tessdataVariant == "fast") "tessdata_fast" else "tessdata"
        val cache = tessdataCacheDir.asFile
        val shaRegex = Regex(""""sha"\s*:\s*"([0-9a-f]{40})"""")
        for (lang in tessdataLanguages) {
            val cached = cache.resolve("$lang.traineddata")
            if (!cached.isFile) {
                logger.lifecycle("$lang : cache absent (${cached.path}) — sera téléchargé au prochain build")
                continue
            }
            val localSha = gitBlobSha1(cached)
            val api = "https://api.github.com/repos/tesseract-ocr/$repo/contents/$lang.traineddata?ref=main"
            val remoteSha = runCatching {
                URI(api).toURL().openStream().use { it.readBytes().toString(Charsets.UTF_8) }
                    .let { shaRegex.find(it)?.groupValues?.get(1) }
            }.getOrNull()
            val etat = when (remoteSha) {
                null -> "upstream injoignable"
                localSha -> "à jour"
                else -> "DIFFÉRENT (upstream $remoteSha)"
            }
            logger.lifecycle("$lang : $etat — ${cached.length()} octets, sha256=${fileSha256(cached)}")
        }
        logger.lifecycle(
            "Rappel : les modèles sont figés au build ; pour forcer un refresh, " +
                "supprimer ${cache.path} puis relancer un build."
        )
    }
}

val prepareVoskModel by tasks.registering {
    group = "build"
    description = "Télécharge (cache) et prépare le modèle Vosk ($VOSK_MODEL_NOM)"
    inputs.property("variante", voskVariante)
    outputs.dir(voskGeneratedAssets)
    doLast {
        val outDir = voskGeneratedAssets.get().asFile.apply { mkdirs() }
        if (voskVariante == "none") {
            logger.lifecycle("Vosk désactivé (-Pvosk=none) — modèle absent des assets.")
            return@doLast
        }
        val cache = voskCacheDir.asFile.apply { mkdirs() }
        val zip = cache.resolve("$VOSK_MODEL_NOM.zip")
        if (!zip.isFile || zip.length() < 1_000_000L) {
            val url = "https://alphacephei.com/vosk/models/$VOSK_MODEL_NOM.zip"
            logger.lifecycle("Téléchargement $VOSK_MODEL_NOM.zip (~41 Mo) …")
            URI(url).toURL().openStream().use { input ->
                zip.outputStream().use { output -> input.copyTo(output) }
            }
            check(zip.isFile && zip.length() >= 1_000_000L) {
                "Échec téléchargement $url"
            }
        }
        val modelDir = outDir.resolve(VOSK_MODEL_NOM)
        if (!modelDir.isDirectory) {
            logger.lifecycle("Extraction $VOSK_MODEL_NOM.zip …")
            ZipInputStream(zip.inputStream()).use { zis ->
                var entry = zis.nextEntry
                while (entry != null) {
                    val target = outDir.resolve(entry.name)
                    if (entry.isDirectory) {
                        target.mkdirs()
                    } else {
                        target.parentFile?.mkdirs()
                        target.outputStream().use { zis.copyTo(it) }
                    }
                    zis.closeEntry()
                    entry = zis.nextEntry
                }
            }
        }
        logger.lifecycle("Modèle Vosk $VOSK_MODEL_NOM prêt.")
    }
}

tasks.matching {
    it.name.startsWith("merge") && it.name.endsWith("Assets") && it.name.contains("Direct")
}.configureEach {
    dependsOn(prepareVoskModel)
}
// `pre<Saveur><Type>Build` ordonne aussi les tâches lint, qui lisent les assets générés :
// sans cela Gradle refuse le build pour dépendance implicite non déclarée.
tasks.matching {
    it.name.startsWith("pre") && it.name.endsWith("Build") && it.name.contains("Direct")
}.configureEach {
    dependsOn(prepareVoskModel)
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:2024.09.02"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.material:material-icons-core")
    implementation("androidx.compose.material:material-icons-extended")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.activity:activity-compose:1.9.2")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("androidx.camera:camera-camera2:1.4.1")
    implementation("androidx.camera:camera-lifecycle:1.4.1")
    implementation("androidx.camera:camera-view:1.4.1")
    implementation("org.opencv:opencv:4.10.0")
    implementation("com.github.adaptech-cz.Tesseract4Android:tesseract4android-openmp:4.9.0")
    implementation("androidx.exifinterface:exifinterface:1.3.7")

    val room = "2.6.1"
    implementation("androidx.room:room-runtime:$room")
    implementation("androidx.room:room-ktx:$room")
    ksp("androidx.room:room-compiler:$room")
    implementation("androidx.navigation:navigation-compose:2.8.2")
    testImplementation("androidx.room:room-testing:$room")

    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.20")
    testImplementation("org.robolectric:robolectric:4.13")
    testImplementation("androidx.test:core:1.6.1")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
    testImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")

    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
    implementation("io.noties.markwon:core:4.6.2")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
    implementation("org.bouncycastle:bcprov-jdk18on:1.85")

    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test:runner:1.6.2")
    androidTestImplementation("androidx.test:rules:1.6.1")

    implementation("com.alphacephei:vosk-android:0.3.47")
}