Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

I18n support with statically generated class #145

Merged
110 changes: 109 additions & 1 deletion shared/domain/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import java.io.InputStreamReader
import java.util.Properties
import kotlin.io.path.Path

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinCocoapods)
Expand Down Expand Up @@ -111,7 +115,7 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.androidx.core)

implementation(libs.koin.core)
implementation(libs.koin.android)
}
Expand Down Expand Up @@ -149,4 +153,108 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

sourceSets["main"].resources.srcDirs("src/commonMain/resources")
}

tasks.withType<Copy> {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

// Create a task class to ensure proper serialization for configuration cache compatibility
abstract class GenerateResourceBundlesTask : DefaultTask() {
@get:InputDirectory
abstract val inputDir: DirectoryProperty

@get:OutputDirectory
abstract val outputDir: DirectoryProperty

@TaskAction
fun generate() {
val resourceDir = inputDir.asFile.get()

val bundleNames: List<String> = listOf(
"default",
"application",
"bisq_easy",
"reputation",
"chat",
"support",
"user",
"network",
"settings",
"payment_method",
"offer",
"mobile" // custom for mobile client
)

val languageCodes = listOf("en", "af_ZA", "cs", "de", "es", "it", "pcm", "pt_BR", "ru")

val bundlesByCode: Map<String, List<ResourceBundle>> = languageCodes.associateWith { languageCode ->
bundleNames.mapNotNull { bundleName ->
val code = if (languageCode.lowercase() == "en") "" else "_$languageCode"
val fileName = "$bundleName$code.properties"
var file = Path(resourceDir.path, fileName).toFile()

if (!file.exists()) {
// Fall back to English default properties if no translation file
file = Path(resourceDir.path, "$bundleName.properties").toFile()
if (!file.exists()) {
logger.warn("File not found: ${file.absolutePath}")
return@mapNotNull null // Skip missing files
}
}

val properties = Properties()

// Use InputStreamReader to ensure UTF-8 encoding
file.inputStream().use { inputStream ->
InputStreamReader(inputStream, Charsets.UTF_8).use { reader ->
properties.load(reader)
}
}

val map = properties.entries.associate { it.key.toString() to it.value.toString() }
ResourceBundle(map, bundleName, languageCode)
}
}

bundlesByCode.forEach { (languageCode, bundles) ->
val outputFile: File = outputDir.get().file("GeneratedResourceBundles_$languageCode.kt").asFile
val generatedCode = StringBuilder().apply {
appendLine("package network.bisq.mobile.i18n")
appendLine()
appendLine("// Auto-generated file. Do not modify manually.")
appendLine("object GeneratedResourceBundles_$languageCode {")
appendLine(" val bundles = mapOf(")
bundles.forEach { bundle ->
appendLine(" \"${bundle.bundleName}\" to mapOf(")
bundle.map.forEach { (key, value) ->
val escapedValue = value
.replace("\\", "\\\\") // Escape backslashes
.replace("\"", "\\\"") // Escape double quotes
.replace("\n", "\\n") // Escape newlines
appendLine(" \"$key\" to \"$escapedValue\",")
}
appendLine(" ),")
}
appendLine(" )")
appendLine("}")
}

outputFile.parentFile.mkdirs()
outputFile.writeText(generatedCode.toString(), Charsets.UTF_8)
}
}

data class ResourceBundle(val map: Map<String, String>, val bundleName: String, val languageCode: String)
}

tasks.register<GenerateResourceBundlesTask>("generateResourceBundles") {
group = "build"
description = "Generate a Kotlin file with hardcoded ResourceBundle data"
inputDir.set(layout.projectDirectory.dir("src/commonMain/resources/mobile"))
// Using build dir still not working on iOS
// Thus we use the source dir as target
outputDir.set(layout.projectDirectory.dir("src/commonMain/kotlin/network/bisq/mobile/i18n"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import kotlinx.serialization.Serializable
import java.io.ByteArrayOutputStream
import java.util.Locale
import java.text.DecimalFormat
import java.io.IOException
import java.io.InputStream
import java.util.Scanner
import java.util.Properties

actual fun getPlatformSettings(): Settings {
return Settings()
Expand All @@ -39,6 +43,17 @@ class AndroidPlatformInfo : PlatformInfo {

actual fun getPlatformInfo(): PlatformInfo = AndroidPlatformInfo()


actual fun loadProperties(fileName: String): Map<String, String> {
val properties = Properties()
val classLoader = Thread.currentThread().contextClassLoader
val resource = classLoader?.getResourceAsStream(fileName)
?: throw IllegalArgumentException("Resource not found: $fileName")
properties.load(resource)

return properties.entries.associate { it.key.toString() to it.value.toString() }
}

@Serializable(with = PlatformImageSerializer::class)
actual class PlatformImage(val bitmap: ImageBitmap) {
actual companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import network.bisq.mobile.domain.data.BackgroundDispatcher
import network.bisq.mobile.domain.data.repository.SettingsRepository
import network.bisq.mobile.domain.service.TrustedNodeService
import network.bisq.mobile.domain.service.bootstrap.ApplicationBootstrapFacade
import network.bisq.mobile.i18n.i18n

class ClientApplicationBootstrapFacade(
private val settingsRepository: SettingsRepository,
Expand Down Expand Up @@ -36,15 +37,15 @@ class ClientApplicationBootstrapFacade(
if (!trustedNodeService.isConnected()) {
try {
trustedNodeService.connect()
setState("Connected to Trusted Node")
setState("bootstrap.connectedToTrustedNode".i18n())
Copy link
Collaborator

Choose a reason for hiding this comment

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

@nostrbuddha for the MVP we are ok as we commited support for english-only initially, but right after we need to use this technique "KEY_STRING".i18n() for every text used in the app after this PR gets merged.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All mobile only strings should end up in the mobile resource file, and then the generate task should be run to update the kotlin files (only engl. if no translations added). We should stick to a good naming convention to keep the strings well organized with first token as general section (e.g. offerbook) then the subsection (e.g. createOffer),... and we use camelCase in bisq 2 so we should stick to same style. Basically it should follow as close as possible the bisq 2 strings convention and existing keys (e.g. if we only add one new string to an area where bisq2 strings are used we should use the same key prefixes.

setProgress(1.0f)
} catch (e: Exception) {
log.e(e) { "Failed to connect to trusted node" }
setState("No connectivity")
setProgress(1.0f)
}
} else {
setState("Connected to Trusted Node")
setState("bootstrap.connectedToTrustedNode".i18n())
setProgress(1.0f)
}
// }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ expect fun getDeviceLanguageCode(): String

expect fun getPlatformInfo(): PlatformInfo

expect fun loadProperties(fileName: String): Map<String, String>

@Serializable(with = PlatformImageSerializer::class)
expect class PlatformImage {
companion object {
Expand Down
Loading
Loading