diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000..b7ee14c16c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,36 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Normalize all text files to LF in the repository and the working tree on
+# every OS, regardless of the contributor's core.autocrlf setting.
+* text=auto eol=lf
+
+# Windows script files must stay CRLF.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Unix scripts and the Gradle wrapper must stay LF.
+*.sh text eol=lf
+gradlew text eol=lf
+
+# Binary files must be left untouched (never EOL-normalized or diffed as text).
+# Images
+*.png binary
+*.webp binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+# Fonts
+*.ttf binary
+*.otf binary
+# JVM / Android artifacts
+*.jar binary
+*.aar binary
+*.so binary
+# Signing keys & keystores
+*.jks binary
+*.keystore binary
+# Archives & databases
+*.zip binary
+*.db binary
diff --git a/.gitignore b/.gitignore
index a0514e3436..37b3b8dc70 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,5 @@ find-latest-release-branch.output
# Claude
/.claude/worktrees/
+/.claude/settings.local.json
CLAUDE.local.md
diff --git a/.worktreeinclude b/.worktreeinclude
new file mode 100644
index 0000000000..08a63f9a4f
--- /dev/null
+++ b/.worktreeinclude
@@ -0,0 +1,5 @@
+# Gitignored files copied into new Claude Code worktrees.
+# Syntax: .gitignore patterns. Only gitignored matches are copied.
+.claude/settings.local.json
+app/google-services.json
+local.properties
diff --git a/app/src/main/res/values-v24/bool.xml b/app/src/main/res/values-v24/bool.xml
index d5677354f0..2f744f862f 100644
--- a/app/src/main/res/values-v24/bool.xml
+++ b/app/src/main/res/values-v24/bool.xml
@@ -1,5 +1,5 @@
-
-
- true
-
-
+
+
+ true
+
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index 0917430c88..7ce840eb60 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -1,12 +1,12 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/bool.xml b/app/src/main/res/values/bool.xml
index d5677354f0..2f744f862f 100644
--- a/app/src/main/res/values/bool.xml
+++ b/app/src/main/res/values/bool.xml
@@ -1,5 +1,5 @@
-
-
- true
-
-
+
+
+ true
+
+
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index df7ac62a96..5863329189 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -1,3 +1,4 @@
+import java.io.ByteArrayOutputStream
import java.security.MessageDigest
plugins {
@@ -61,15 +62,15 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
require(actual == expected) {
"Design tokens are out of date!\n" +
- " ds-tokens hash: $actual\n" +
- " generated hash: $expected\n" +
+ " computed from ds-tokens: $actual\n" +
+ " committed .tokens-hash: $expected\n" +
"Run the token generator: cd core/ui/token-gen && npm run build"
}
stampFile.get().asFile.writeText(actual)
}
- private fun hashTreeHex(root: java.io.File, extension: String): String {
+ private fun hashTreeHex(root: File, extension: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val files = root.walkTopDown()
.filter { it.isFile && it.extension == extension }
@@ -79,12 +80,27 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
for (file in files) {
digest.update(file.relativeTo(root).invariantSeparatorsPath.toByteArray())
digest.update(nul)
- digest.update(file.readBytes())
+ digest.update(file.readBytes().stripCr())
digest.update(nul)
}
return digest.digest()
.joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') }
}
+
+ /**
+ * Strips CR (0x0D) bytes so the token hash ignores CRLF vs LF line endings. The ds-tokens
+ * submodule is not covered by this repo's .gitattributes, so its .json/.svg sources may be
+ * checked out with CRLF on some platforms; without this the verification is non-deterministic.
+ * Removes lone CRs too — safe for UTF-8 sources, where 0x0D never appears inside a
+ * multi-byte sequence. Must stay byte-for-byte identical to stripCr() in token-gen/hash-util.mjs.
+ */
+ private fun ByteArray.stripCr(): ByteArray {
+ val cr: Byte = 0x0D
+ if (none { it == cr }) return this
+ val out = ByteArrayOutputStream(size)
+ for (b in this) if (b != cr) out.write(b.toInt())
+ return out.toByteArray()
+ }
}
android {
namespace = "com.tangem.core.ui"
diff --git a/core/ui/token-gen/build-icons.mjs b/core/ui/token-gen/build-icons.mjs
index 6313846665..3a8e28405f 100644
--- a/core/ui/token-gen/build-icons.mjs
+++ b/core/ui/token-gen/build-icons.mjs
@@ -2,6 +2,7 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
+import { stripCr, compareCodeUnits } from './hash-util.mjs';
// ── Paths ──────────────────────────────────────────────────────────────────────
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -252,16 +253,17 @@ object Icons
function computeIconsHash() {
const files = [...walkSvgs(iconsDir)];
- files.sort((a, b) => {
- const ra = path.relative(iconsDir, a).split(path.sep).join('/');
- const rb = path.relative(iconsDir, b).split(path.sep).join('/');
- return ra.localeCompare(rb);
- });
+ // Code-unit order (not localeCompare) to match the Kotlin verifier's invariantSeparatorsPath
+ // sort deterministically across locales/ICU versions — see hash-util.mjs.
+ files.sort((a, b) => compareCodeUnits(
+ path.relative(iconsDir, a).split(path.sep).join('/'),
+ path.relative(iconsDir, b).split(path.sep).join('/'),
+ ));
const hash = crypto.createHash('sha256');
for (const file of files) {
hash.update(path.relative(iconsDir, file).split(path.sep).join('/'));
hash.update('\0');
- hash.update(fs.readFileSync(file));
+ hash.update(stripCr(fs.readFileSync(file)));
hash.update('\0');
}
return hash.digest('hex');
diff --git a/core/ui/token-gen/build-tokens.mjs b/core/ui/token-gen/build-tokens.mjs
index 5c185d8388..6f7c18527b 100644
--- a/core/ui/token-gen/build-tokens.mjs
+++ b/core/ui/token-gen/build-tokens.mjs
@@ -5,6 +5,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { buildIcons } from './build-icons.mjs';
+import { stripCr, compareCodeUnits } from './hash-util.mjs';
// ── Paths ──────────────────────────────────────────────────────────────────────
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -792,18 +793,18 @@ function computeTokensHash() {
}
}
walk(tokensDir);
- // Sort by relative path with forward slashes to match Gradle's invariantSeparatorsPath sorting
- files.sort((a, b) => {
- const ra = path.relative(tokensDir, a).split(path.sep).join('/');
- const rb = path.relative(tokensDir, b).split(path.sep).join('/');
- return ra.localeCompare(rb);
- });
+ // Sort by forward-slash relative path in UTF-16 code-unit order to match the Kotlin verifier's
+ // sortedBy { …invariantSeparatorsPath } (String.compareTo) deterministically — see hash-util.mjs.
+ files.sort((a, b) => compareCodeUnits(
+ path.relative(tokensDir, a).split(path.sep).join('/'),
+ path.relative(tokensDir, b).split(path.sep).join('/'),
+ ));
const hash = crypto.createHash('sha256');
for (const file of files) {
hash.update(path.relative(tokensDir, file).split(path.sep).join('/'));
hash.update('\0');
- hash.update(fs.readFileSync(file));
+ hash.update(stripCr(fs.readFileSync(file)));
hash.update('\0');
}
return hash.digest('hex');
diff --git a/core/ui/token-gen/hash-util.mjs b/core/ui/token-gen/hash-util.mjs
new file mode 100644
index 0000000000..c52ac7b8d6
--- /dev/null
+++ b/core/ui/token-gen/hash-util.mjs
@@ -0,0 +1,31 @@
+// Shared hashing helpers for the design-token generators (build-tokens.mjs / build-icons.mjs).
+// Kept in one place because the exact byte stream AND file ordering produced here must stay
+// identical to the Kotlin verifier in core/ui/build.gradle.kts (VerifyDesignTokensTask):
+// any drift silently breaks the token hash gate.
+
+/**
+ * Strip every CR (0x0D) byte so the hash ignores CRLF vs LF line endings. The ds-tokens submodule
+ * is not covered by the parent repo's .gitattributes, so its .json/.svg sources may be checked out
+ * with CRLF on some platforms. Lone CRs are dropped too — safe here because these sources are UTF-8
+ * (0x0D never appears inside a multi-byte sequence) and carry no bare CR.
+ * Must stay byte-for-byte identical to ByteArray.stripCr() in core/ui/build.gradle.kts.
+ */
+export function stripCr(buf) {
+ const out = Buffer.allocUnsafe(buf.length);
+ let n = 0;
+ for (let i = 0; i < buf.length; i++) {
+ if (buf[i] !== 0x0d) out[n++] = buf[i];
+ }
+ return out.subarray(0, n);
+}
+
+/**
+ * Compare two forward-slash relative paths by UTF-16 code unit — identical to Kotlin's
+ * String.compareTo used by `sortedBy { …invariantSeparatorsPath }` in the verifier.
+ * Do NOT use String.prototype.localeCompare for file ordering: it is locale/ICU-version dependent
+ * (differs across machines) and case-insensitive at the primary level (disagrees with the Kotlin
+ * code-unit sort) — either would reorder files and change the hash.
+ */
+export function compareCodeUnits(a, b) {
+ return a < b ? -1 : a > b ? 1 : 0;
+}
diff --git a/gradlew.bat b/gradlew.bat
index 24467a141f..9618d8d960 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,100 +1,100 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto init
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega