1/* This file is part of the KDE libraries
2 SPDX-FileCopyrightText: 2001 Hans Petter Bieker <bieker@kde.org>
3 SPDX-FileCopyrightText: 2012, 2013 Chusslove Illich <caslav.ilic@gmx.net>
4
5 SPDX-License-Identifier: LGPL-2.0-or-later
6*/
7
8#include "config.h"
9
10#include <kcatalog_p.h>
11
12#include "ki18n_logging.h"
13
14#include <QByteArray>
15#include <QCoreApplication>
16#include <QDebug>
17#include <QDir>
18#include <QFile>
19#include <QFileInfo>
20#include <QMutexLocker>
21#include <QSet>
22#include <QStandardPaths>
23#include <QStringList>
24
25#ifdef Q_OS_ANDROID
26#include <QJniEnvironment>
27#include <QJniObject>
28
29#include <android/asset_manager.h>
30#include <android/asset_manager_jni.h>
31
32#if __ANDROID_API__ < 23
33#include <dlfcn.h>
34#endif
35#endif
36
37#include <cerrno>
38#include <cstdio>
39#include <cstring>
40#include <locale.h>
41#include <stdlib.h>
42
43#include "gettext.h" // Must be included after <stdlib.h>
44
45// not defined on win32 :(
46#ifdef _WIN32
47#ifndef LC_MESSAGES
48#define LC_MESSAGES 42
49#endif
50#endif
51
52#if HAVE_NL_MSG_CAT_CNTR
53extern "C" int Q_DECL_IMPORT _nl_msg_cat_cntr;
54#endif
55
56static char *s_langenv = nullptr;
57static const int s_langenvMaxlen = 64;
58// = "LANGUAGE=" + 54 chars for language code + terminating null \0 character
59
60static void copyToLangArr(const QByteArray &lang)
61{
62 const int bytes = std::snprintf(s: s_langenv, maxlen: s_langenvMaxlen, format: "LANGUAGE=%s", lang.constData());
63 if (bytes < 0) {
64 qCWarning(KI18N) << "There was an error while writing LANGUAGE environment variable:" << std::strerror(errno);
65 } else if (bytes > (s_langenvMaxlen - 1)) { // -1 for the \0 character
66 qCWarning(KI18N) << "The value of the LANGUAGE environment variable:" << lang << "( size:" << lang.size() << "),\n"
67 << "was longer than (and consequently truncated to) the max. length of:" << (s_langenvMaxlen - strlen(s: "LANGUAGE=") - 1);
68 }
69}
70
71class KCatalogStaticData
72{
73public:
74 KCatalogStaticData()
75 {
76#ifdef Q_OS_ANDROID
77 QJniEnvironment env;
78 QJniObject context = QNativeInterface::QAndroidApplication::context();
79 m_assets = context.callObjectMethod("getAssets", "()Landroid/content/res/AssetManager;");
80 m_assetMgr = AAssetManager_fromJava(env.jniEnv(), m_assets.object());
81
82#if __ANDROID_API__ < 23
83 fmemopenFunc = reinterpret_cast<decltype(fmemopenFunc)>(dlsym(RTLD_DEFAULT, "fmemopen"));
84#endif
85#endif
86 }
87
88 QHash<QByteArray /*domain*/, QString /*directory*/> customCatalogDirs;
89 QMutex mutex;
90
91#ifdef Q_OS_ANDROID
92 QJniObject m_assets;
93 AAssetManager *m_assetMgr = nullptr;
94#if __ANDROID_API__ < 23
95 FILE *(*fmemopenFunc)(void *, size_t, const char *);
96#endif
97#endif
98};
99
100Q_GLOBAL_STATIC(KCatalogStaticData, catalogStaticData)
101
102class KCatalogPrivate
103{
104public:
105 KCatalogPrivate();
106
107 QByteArray domain;
108 QByteArray language;
109 QByteArray localeDir;
110
111 QByteArray systemLanguage;
112 bool bindDone;
113
114 static QByteArray currentLanguage;
115
116 void setupGettextEnv();
117 void resetSystemLanguage();
118};
119
120KCatalogPrivate::KCatalogPrivate()
121 : bindDone(false)
122{
123}
124
125QByteArray KCatalogPrivate::currentLanguage;
126
127KCatalog::KCatalog(const QByteArray &domain, const QString &language_)
128 : d(new KCatalogPrivate)
129{
130 d->domain = domain;
131 d->language = QFile::encodeName(fileName: language_);
132 d->localeDir = QFile::encodeName(fileName: catalogLocaleDir(domain, language: language_));
133
134 if (!d->localeDir.isEmpty()) {
135 // Always get translations in UTF-8, regardless of user's environment.
136 bind_textdomain_codeset(domainname: d->domain, codeset: "UTF-8");
137
138 // Invalidate current language, to trigger binding at next translate call.
139 KCatalogPrivate::currentLanguage.clear();
140
141 if (!s_langenv) {
142 // Call putenv only here, to initialize LANGUAGE variable.
143 // Later only change s_langenv to what is currently needed.
144 // This doesn't work on Windows though, so there we need putenv calls on every change
145 s_langenv = new char[s_langenvMaxlen];
146 copyToLangArr(lang: qgetenv(varName: "LANGUAGE"));
147 putenv(string: s_langenv);
148 }
149 }
150}
151
152KCatalog::~KCatalog() = default;
153
154#if defined(Q_OS_ANDROID) && __ANDROID_API__ < 23
155static QString androidUnpackCatalog(const QString &relpath)
156{
157 // the catalog files are no longer extracted to the local file system
158 // by androiddeployqt starting with Qt 5.14, libintl however needs
159 // local files rather than qrc: or asset: URLs, so we unpack the .mo
160 // files on demand to the local cache folder
161
162 const QString cachePath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QLatin1String("/org.kde.ki18n/") + relpath;
163 QFileInfo cacheFile(cachePath);
164 if (cacheFile.exists()) {
165 return cachePath;
166 }
167
168 const QString assetPath = QLatin1String("assets:/share/locale/") + relpath;
169 if (!QFileInfo::exists(assetPath)) {
170 return {};
171 }
172
173 QDir().mkpath(cacheFile.absolutePath());
174 QFile f(assetPath);
175 if (!f.copy(cachePath)) {
176 qCWarning(KI18N) << "Failed to copy catalog:" << f.errorString() << assetPath << cachePath;
177 return {};
178 }
179 return cachePath;
180}
181#endif
182
183QString KCatalog::catalogLocaleDir(const QByteArray &domain, const QString &language)
184{
185 QString relpath = QStringLiteral("%1/LC_MESSAGES/%2.mo").arg(args: language, args: QFile::decodeName(localFileName: domain));
186
187 {
188 QMutexLocker lock(&catalogStaticData->mutex);
189 const QString customLocaleDir = catalogStaticData->customCatalogDirs.value(key: domain);
190 const QString filename = customLocaleDir + QLatin1Char('/') + relpath;
191 if (!customLocaleDir.isEmpty() && QFileInfo::exists(file: filename)) {
192#if defined(Q_OS_ANDROID)
193 // The exact file name must be returned on Android because libintl-lite loads a catalog by filename with bindtextdomain()
194 return filename;
195#else
196 return customLocaleDir;
197#endif
198 }
199 }
200
201#if defined(Q_OS_ANDROID)
202#if __ANDROID_API__ < 23
203 // fall back to copying the catalog to the file system on old systems
204 // without fmemopen()
205 if (!catalogStaticData->fmemopenFunc) {
206 return androidUnpackCatalog(relpath);
207 }
208#endif
209 const QString assetPath = QLatin1String("assets:/share/locale/") + relpath;
210 if (!QFileInfo::exists(assetPath)) {
211 return {};
212 }
213 return assetPath;
214
215#else
216 QString file = QStandardPaths::locate(type: QStandardPaths::GenericDataLocation, QStringLiteral("locale/") + relpath);
217#ifdef Q_OS_WIN
218 // QStandardPaths fails on Windows for executables that aren't properly deployed yet, such as unit tests
219 if (file.isEmpty()) {
220 const QString p = QLatin1String(INSTALLED_LOCALE_PREFIX) + QLatin1String("/bin/data/locale/") + relpath;
221 if (QFile::exists(p)) {
222 file = p;
223 }
224 }
225#endif
226
227 QString localeDir;
228 if (!file.isEmpty()) {
229 // Path of the locale/ directory must be returned.
230 localeDir = QFileInfo(file.left(n: file.size() - relpath.size())).absolutePath();
231 }
232 return localeDir;
233#endif
234}
235
236QSet<QString> KCatalog::availableCatalogLanguages(const QByteArray &domain_)
237{
238 QString domain = QFile::decodeName(localFileName: domain_);
239 QStringList localeDirPaths = QStandardPaths::locateAll(type: QStandardPaths::GenericDataLocation, QStringLiteral("locale"), options: QStandardPaths::LocateDirectory);
240#ifdef Q_OS_WIN
241 // QStandardPaths fails on Windows for executables that aren't properly deployed yet, such as unit tests
242 localeDirPaths += QLatin1String(INSTALLED_LOCALE_PREFIX) + QLatin1String("/bin/data/locale/");
243#endif
244
245 {
246 QMutexLocker lock(&catalogStaticData->mutex);
247 auto it = catalogStaticData->customCatalogDirs.constFind(key: domain_);
248 if (it != catalogStaticData->customCatalogDirs.constEnd()) {
249 localeDirPaths.prepend(t: *it);
250 }
251 }
252
253 QSet<QString> availableLanguages;
254 for (const QString &localDirPath : std::as_const(t&: localeDirPaths)) {
255 QDir localeDir(localDirPath);
256 const QStringList languages = localeDir.entryList(filters: QDir::AllDirs | QDir::NoDotAndDotDot);
257 for (const QString &language : languages) {
258 QString relPath = QStringLiteral("%1/LC_MESSAGES/%2.mo").arg(args: language, args&: domain);
259 if (localeDir.exists(name: relPath)) {
260 availableLanguages.insert(value: language);
261 }
262 }
263 }
264 return availableLanguages;
265}
266
267#ifdef Q_OS_ANDROID
268static void androidAssetBindtextdomain(const QByteArray &domain, const QByteArray &localeDir)
269{
270 AAsset *asset = AAssetManager_open(catalogStaticData->m_assetMgr, localeDir.mid(8).constData(), AASSET_MODE_UNKNOWN);
271 if (!asset) {
272 qWarning() << "unable to load asset" << localeDir;
273 return;
274 }
275
276 off64_t size = AAsset_getLength64(asset);
277 const void *buffer = AAsset_getBuffer(asset);
278#if __ANDROID_API__ >= 23
279 FILE *moFile = fmemopen(const_cast<void *>(buffer), size, "r");
280#else
281 FILE *moFile = catalogStaticData->fmemopenFunc(const_cast<void *>(buffer), size, "r");
282#endif
283 loadMessageCatalogFile(domain, moFile);
284 fclose(moFile);
285 AAsset_close(asset);
286}
287#endif
288
289void KCatalogPrivate::setupGettextEnv()
290{
291 // Point Gettext to current language, recording system value for recovery.
292 systemLanguage = qgetenv(varName: "LANGUAGE");
293 if (systemLanguage != language) {
294 // putenv has been called in the constructor,
295 // it is enough to change the string set there.
296 copyToLangArr(lang: language);
297#ifdef Q_OS_WINDOWS
298 putenv(s_langenv);
299#endif
300 }
301
302 // Rebind text domain if language actually changed from the last time,
303 // as locale directories may differ for different languages of same catalog.
304 if (language != currentLanguage || !bindDone) {
305 Q_ASSERT_X(QCoreApplication::instance(), "KCatalogPrivate::setupGettextEnv", "You need to instantiate a Q*Application before using KCatalog");
306 if (!QCoreApplication::instance()) {
307 qCWarning(KI18N) << "KCatalog being used without a Q*Application instance. Some translations won't work";
308 }
309
310 currentLanguage = language;
311 bindDone = true;
312
313 // qDebug() << "bindtextdomain" << domain << localeDir;
314#ifdef Q_OS_ANDROID
315 if (localeDir.startsWith("assets:/")) {
316 androidAssetBindtextdomain(domain, localeDir);
317 } else {
318 bindtextdomain(domain, localeDir);
319 }
320#else
321 bindtextdomain(domainname: domain, dirname: localeDir);
322#endif
323
324#if HAVE_NL_MSG_CAT_CNTR
325 // Magic to make sure GNU Gettext doesn't use stale cached translation
326 // from previous language.
327 ++_nl_msg_cat_cntr;
328#endif
329 }
330}
331
332void KCatalogPrivate::resetSystemLanguage()
333{
334 if (language != systemLanguage) {
335 copyToLangArr(lang: systemLanguage);
336#ifdef Q_OS_WINDOWS
337 putenv(s_langenv);
338#endif
339 }
340}
341
342QString KCatalog::translate(const QByteArray &msgid) const
343{
344 if (!d->localeDir.isEmpty()) {
345 QMutexLocker locker(&catalogStaticData()->mutex);
346 d->setupGettextEnv();
347 const char *msgid_char = msgid.constData();
348 const char *msgstr = dgettext(domainname: d->domain.constData(), msgid: msgid_char);
349 d->resetSystemLanguage();
350 return msgstr != msgid_char // Yes we want pointer comparison
351 ? QString::fromUtf8(utf8: msgstr)
352 : QString();
353 } else {
354 return QString();
355 }
356}
357
358QString KCatalog::translate(const QByteArray &msgctxt, const QByteArray &msgid) const
359{
360 if (!d->localeDir.isEmpty()) {
361 QMutexLocker locker(&catalogStaticData()->mutex);
362 d->setupGettextEnv();
363 const char *msgid_char = msgid.constData();
364 const char *msgstr = dpgettext_expr(d->domain.constData(), msgctxt.constData(), msgid_char);
365 d->resetSystemLanguage();
366 return msgstr != msgid_char // Yes we want pointer comparison
367 ? QString::fromUtf8(utf8: msgstr)
368 : QString();
369 } else {
370 return QString();
371 }
372}
373
374QString KCatalog::translate(const QByteArray &msgid, const QByteArray &msgid_plural, qulonglong n) const
375{
376 if (!d->localeDir.isEmpty()) {
377 QMutexLocker locker(&catalogStaticData()->mutex);
378 d->setupGettextEnv();
379 const char *msgid_char = msgid.constData();
380 const char *msgid_plural_char = msgid_plural.constData();
381 const char *msgstr = dngettext(domainname: d->domain.constData(), msgid1: msgid_char, msgid2: msgid_plural_char, n: n);
382 d->resetSystemLanguage();
383 // If original and translation are same, dngettext will return
384 // the original pointer, which is generally fine, except in
385 // the corner cases where e.g. msgstr[1] is same as msgid.
386 // Therefore check for pointer difference only with msgid or
387 // only with msgid_plural, and not with both.
388 return (n == 1 && msgstr != msgid_char) || (n != 1 && msgstr != msgid_plural_char) ? QString::fromUtf8(utf8: msgstr) : QString();
389 } else {
390 return QString();
391 }
392}
393
394QString KCatalog::translate(const QByteArray &msgctxt, const QByteArray &msgid, const QByteArray &msgid_plural, qulonglong n) const
395{
396 if (!d->localeDir.isEmpty()) {
397 QMutexLocker locker(&catalogStaticData()->mutex);
398 d->setupGettextEnv();
399 const char *msgid_char = msgid.constData();
400 const char *msgid_plural_char = msgid_plural.constData();
401 const char *msgstr = dnpgettext_expr(d->domain.constData(), msgctxt.constData(), msgid_char, msgid_plural_char, n);
402 d->resetSystemLanguage();
403 return (n == 1 && msgstr != msgid_char) || (n != 1 && msgstr != msgid_plural_char) ? QString::fromUtf8(utf8: msgstr) : QString();
404 } else {
405 return QString();
406 }
407}
408
409void KCatalog::addDomainLocaleDir(const QByteArray &domain, const QString &path)
410{
411 QMutexLocker locker(&catalogStaticData()->mutex);
412 catalogStaticData()->customCatalogDirs.insert(key: domain, value: path);
413}
414

source code of ki18n/src/i18n/kcatalog.cpp