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

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