Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
steps:
- uses: actions/checkout@v3
- name: set up JDK 21
Expand All @@ -23,10 +26,29 @@ jobs:
- name: Build with Gradle
run: ./gradlew build

- name: Run integration tests
run: ./gradlew :lib:integrationTest

- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: lib/build/test-results/**/*.xml

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
lib/build/reports/tests/test/
lib/build/reports/tests/integrationTest/

- name: Validate Maven Central publishing setup
run: ./gradlew :lib:publishToMavenLocal --dry-run

- name: Validate Gradle Plugin Portal publishing setup
continue-on-error: ${{ github.base_ref != 'production' && github.ref_name != 'production' }}
run: ./gradlew :lib:publishPlugins --validate-only
env:
GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
Expand Down
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
[versions]
agp = "9.0.0"
junitJupiter = "5.14.3"
kotestAssertionsCore = "5.9.1"
kotlin = "2.2.20"
composeBom = "2025.10.00"
activityCompose = "1.11.0"
Expand All @@ -25,6 +27,9 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
gradle = { module = "com.android.tools.build:gradle", version.ref = "agp" }
gradle-api = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotestAssertionsCore" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
Expand Down
28 changes: 26 additions & 2 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,40 @@ plugins {

dependencies {
implementation(gradleApi())
testImplementation(gradleTestKit())
testRuntimeOnly(libs.cglib.nodep)
implementation(libs.kotlin.gradle.plugin)
implementation(libs.gradle)
implementation(libs.gradle.api)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.serialization.json)

testImplementation(gradleTestKit())
testRuntimeOnly(libs.cglib.nodep)
testImplementation(libs.kotest.assertions.core)
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
}

sourceSets {
create("integrationTest") {
compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output
runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output
}
}

kotlin.target.compilations["integrationTest"].associateWith(kotlin.target.compilations["main"])

configurations["integrationTestImplementation"].extendsFrom(configurations.testImplementation.get())
configurations["integrationTestRuntimeOnly"].extendsFrom(configurations.testRuntimeOnly.get())

tasks.register<Test>("integrationTest") {
description = "Runs integration tests."
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath
}

tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.appswithlove.loco.http

import com.appswithlove.loco.plugin.LocoConfig
import com.sun.net.httpserver.HttpServer
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.InetSocketAddress

class DefaultLocoHttpClientTest {

private lateinit var server: HttpServer
private var capturedUri: String = ""

@BeforeEach
fun setUp() {
server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/") { exchange ->
capturedUri = exchange.requestURI.toString()
val response = "<resources></resources>".toByteArray()
exchange.sendResponseHeaders(200, response.size.toLong())
exchange.responseBody.use { it.write(response) }
}
server.start()
}

@AfterEach
fun tearDown() {
server.stop(0)
}

private fun config(block: LocoConfig.() -> Unit = {}): LocoConfig = LocoConfig().apply {
apiKey = "test-key"
locoBaseUrl = "http://localhost:${server.address.port}"
block()
}

@Test
fun fetchTranslationWithTagsIncludesFilterParam() {
val config = config { tags = "android" }
DefaultLocoHttpClient().fetchTranslation(config, "en")

capturedUri shouldContain "filter=android"
}

@Test
fun fetchTranslationWithFallbackLangIncludesFallbackParam() {
val config = config { fallbackLang = "en" }
DefaultLocoHttpClient().fetchTranslation(config, "de")

capturedUri shouldContain "fallback=en"
}

@Test
fun fetchTranslationWithOrderByAssetIdIncludesOrderParam() {
val config = config { orderByAssetId = true }
DefaultLocoHttpClient().fetchTranslation(config, "en")

capturedUri shouldContain "order=id"
}

@Test
fun fetchTranslationWithOrderByAssetIdFalseOmitsOrderParam() {
val config = config { orderByAssetId = false }
DefaultLocoHttpClient().fetchTranslation(config, "en")

capturedUri shouldNotContain "order=id"
}

@Test
fun fetchTranslationWithHideCommentsSetsNoCommentsTrue() {
val config = config { hideComments = true }
DefaultLocoHttpClient().fetchTranslation(config, "en")

capturedUri shouldContain "no-comments=true"
}

@Test
fun fetchTranslationWithIndexIncludesIndexParam() {
val config = config { index = "name" }
DefaultLocoHttpClient().fetchTranslation(config, "en")

capturedUri shouldContain "index=name"
}

@Test
fun fetchTranslationWithStatusIncludesStatusParam() {
val config = config { status = "translated" }
DefaultLocoHttpClient().fetchTranslation(config, "en")

capturedUri shouldContain "status=translated"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.appswithlove.loco.http

import com.appswithlove.loco.Constants
import com.appswithlove.loco.plugin.LocoConfig
import java.net.HttpURLConnection
import java.net.URL

internal class DefaultLocoHttpClient : LocoHttpClient {

override fun fetchLocales(apiKey: String): String {
val connection =
(URL(Constants.LOCO_LOCALES_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Authorization", "Loco $apiKey")
setRequestProperty("Accept", "application/json")
connectTimeout = 10000
readTimeout = 10000
}
return connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
}

override fun fetchTranslation(locoConfig: LocoConfig, language: String): String {
var parameter = "?no-comments=${locoConfig.hideComments}"
locoConfig.tags?.let { parameter += "&filter=$it" }
locoConfig.index?.let { parameter += "&index=$it" }
locoConfig.status?.let { parameter += "&status=$it" }
locoConfig.fallbackLang?.let { parameter += "&fallback=$it" }
if (locoConfig.orderByAssetId) parameter += "&order=id"

val connection =
(URL("${locoConfig.locoBaseUrl}/$language.xml$parameter").openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
addRequestProperty("Authorization", "Loco ${locoConfig.apiKey}")
addRequestProperty("Accept-Charset", "utf-8")
connectTimeout = 10000
readTimeout = 10000
}

if (connection.responseCode != 200) {
val error = (connection.errorStream ?: connection.inputStream).bufferedReader()
.use { it.readText() }
throw IllegalStateException("Request failed with code ${connection.responseCode}: $error")
}

return connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.appswithlove.loco.http

import com.appswithlove.loco.plugin.LocoConfig

internal interface LocoHttpClient {
fun fetchLocales(apiKey: String): String
fun fetchTranslation(locoConfig: LocoConfig, language: String): String
}
93 changes: 19 additions & 74 deletions lib/src/main/kotlin/com/appswithlove/loco/util/TaskUtils.kt
Original file line number Diff line number Diff line change
@@ -1,67 +1,26 @@
package com.appswithlove.loco.util

import com.appswithlove.loco.Constants
import com.appswithlove.loco.dto.LocaleDto
import com.appswithlove.loco.http.DefaultLocoHttpClient
import com.appswithlove.loco.http.LocoHttpClient
import com.appswithlove.loco.plugin.LocoConfig
import kotlinx.serialization.json.Json
import org.gradle.api.GradleException
import java.io.File
import java.net.HttpURLConnection
import java.net.URL

/**
* Helper to generate XML file from a loco config
*/
object TaskUtils {

fun generate(locoConfig: LocoConfig) {
// Determine whether it should show comments or not
var parameter = "?no-comments=${locoConfig.hideComments}"

// Add tags filter
locoConfig.tags?.let {
parameter += "&filter=$it"
}

// Add index
locoConfig.index?.let {
parameter += "&index=$it"
}

// Add status filter
locoConfig.status?.let {
parameter += "&status=$it"
}

// If a fallback language was specified, include it as a parameter
locoConfig.fallbackLang?.let {
parameter += "&fallback=$it"
}

// Determine whether assets should be ordered alphabetically by Asset ID, include parameter if necessary
if (locoConfig.orderByAssetId) {
parameter += "&order=id"
}

internal fun generate(
locoConfig: LocoConfig,
httpClient: LocoHttpClient = DefaultLocoHttpClient(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice improvement!

) {
val languages: List<String> = locoConfig.lang?.takeIf { it.isNotEmpty() } ?: run {
println("Languages are not specified in Loco config. Fetching all languages from the project.")
val response = fetchAllLanguages(locoConfig.apiKey)
response.ifEmpty { emptyList() }
fetchAllLanguages(httpClient, locoConfig.apiKey).ifEmpty { emptyList() }
}

for (langEntry in languages) {
var lang = langEntry
val baseUrl = URL("${locoConfig.locoBaseUrl}/$lang.xml$parameter")

val connection = baseUrl.openConnection() as HttpURLConnection
connection.addRequestProperty("Authorization", "Loco ${locoConfig.apiKey}")
connection.addRequestProperty("Accept-Charset", "utf-8")
connection.connectTimeout = 10000
connection.readTimeout = 10000
connection.doOutput = true
connection.requestMethod = "GET"

var text = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
var text = httpClient.fetchTranslation(locoConfig, lang)

// Replace placeholders
locoConfig.placeholderPattern?.let { pattern ->
Expand Down Expand Up @@ -90,15 +49,13 @@ object TaskUtils {
}

// In some rare cases, the encoding parameter in the xml-tag is 'utf8' instead of 'utf-8'
// todo: if there's a better, more reliable solution to handle this, please submit a PR.
val wrongXmlString = """<?xml version="1.0" encoding="utf8"?>"""
if (text.startsWith(wrongXmlString)) {
val expectedXmlString = """<?xml version="1.0" encoding="utf-8"?>"""
text = text.replaceFirst(wrongXmlString, expectedXmlString)
}

if (lang == locoConfig.defLang) {
// Ignore missing translation warnings for every string inside the default strings file
if (locoConfig.ignoreMissingTranslationWarnings) {
text = text.replaceFirst(
"<resources",
Expand All @@ -114,31 +71,10 @@ object TaskUtils {
}
}

private fun saveFile(locoConfig: LocoConfig, text: String, appendix: String = "") {
val directory = File("${locoConfig.resDir}/values$appendix/")
if (!directory.exists()) {
directory.mkdirs()
}

val file = File(directory.absolutePath, "${locoConfig.fileName}.xml")
file.writeText(text, Charsets.UTF_8)
}

fun fetchAllLanguages(apiKey: String?): List<String> {
internal fun fetchAllLanguages(httpClient: LocoHttpClient, apiKey: String?): List<String> {
if (apiKey != null) {
try {
val url = URL(Constants.LOCO_LOCALES_URL)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Authorization", "Loco $apiKey")
setRequestProperty("Accept", "application/json")
connectTimeout = 10000
readTimeout = 10000
}

val response =
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }

val response = httpClient.fetchLocales(apiKey)
val json = Json { ignoreUnknownKeys = true }
return json.decodeFromString<List<LocaleDto>>(response).map { it.code }
} catch (e: Exception) {
Expand All @@ -148,4 +84,13 @@ object TaskUtils {
throw GradleException("Can't fetch languages. API key is missing in Loco config.")
}
}

private fun saveFile(locoConfig: LocoConfig, text: String, appendix: String = "") {
val directory = File("${locoConfig.resDir}/values$appendix/")
if (!directory.exists()) {
directory.mkdirs()
}
val file = File(directory.absolutePath, "${locoConfig.fileName}.xml")
file.writeText(text, Charsets.UTF_8)
}
}
Loading
Loading