First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# element://call?url=some-encoded-url
# For instance
# element://call?url=https%3A%2F%2Fcall.element.io%2FTestElementCall
adb shell am start -a android.intent.action.VIEW -d element://call?url=https%3A%2F%2Fcall.element.io%2FTestElementCall

View File

@@ -0,0 +1,14 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# io.element.call:/?url=some-encoded-url
# For instance
# io.element.call:/?url=https%3A%2F%2Fcall.element.io%2FTestElementCall
adb shell am start -a android.intent.action.VIEW -d io.element.call:/?url=https%3A%2F%2Fcall.element.io%2FTestElementCall

14
tools/adb/callLinkHttps.sh Executable file
View File

@@ -0,0 +1,14 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# https://call.element.io/*
# For instance
# https://call.element.io/TestElementCall
adb shell am start -a android.intent.action.VIEW -d https://call.element.io/TestElementCall

19
tools/adb/deeplink.sh Executable file
View File

@@ -0,0 +1,19 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# elementx://open/{sessionId} to open a session
# elementx://open/{sessionId}/{roomId} to open a room
# elementx://open/{sessionId}/{roomId}/{eventId} to open a thread
# Open a session
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org
# Open a room
adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org
# Open a thread
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId

10
tools/adb/deeplink_external.sh Executable file
View File

@@ -0,0 +1,10 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE \
-d "https://app.element.io/#/room/!cuqHozLHNBgupgLMKN:matrix.org/%24LZDOueY3R8OD2ZYf8FLKtu95aF7imLBC3F5TIUj-4cc"

10
tools/adb/deeplink_matrix.sh Executable file
View File

@@ -0,0 +1,10 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
adb shell am start -a android.intent.action.VIEW \
-d "matrix:r/element-android:matrix.org"

10
tools/adb/deeplink_matrixto.sh Executable file
View File

@@ -0,0 +1,10 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
adb shell am start -a android.intent.action.VIEW \
-d "element://room/%23element-android%3Amatrix.org"

13
tools/adb/deeplink_mobile.sh Executable file
View File

@@ -0,0 +1,13 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org
adb shell am start -a android.intent.action.VIEW \
-d "https://mobile.element.io/element/?account_provider=element.io\\&login_hint=mxid:@alice:element.io"

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_your_app_with_app_standby
echo " => Standby OFF"
set -x
package_name="io.element.android.x.debug"
adb shell dumpsys battery reset
adb shell am set-inactive "${package_name}" false
adb shell am get-inactive "${package_name}"
tools/adb/print_device_state.sh

17
tools/adb/disable_doze_mode.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
echo " => Disable doze mode"
set -x
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
tools/adb/print_device_state.sh

10
tools/adb/disable_talkback.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
adb shell settings put secure enabled_accessibility_services null

19
tools/adb/enable_app_standby.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_your_app_with_app_standby
echo " => Standby ON"
set -x
package_name="io.element.android.x.debug"
adb shell dumpsys battery unplug
adb shell am set-inactive "${package_name}" true
adb shell am get-inactive "${package_name}"
tools/adb/print_device_state.sh

17
tools/adb/enable_doze_mode.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
echo " => Enable doze mode"
set -x
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
tools/adb/print_device_state.sh

9
tools/adb/enable_talkback.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
adb shell settings put secure enabled_accessibility_services com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService

15
tools/adb/oidc.sh Executable file
View File

@@ -0,0 +1,15 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# Error
# adb shell am start -a android.intent.action.VIEW -d "io.element.android:/?error=access_denied\\&state=IFF1UETGye2ZA8pO"
# Success
adb shell am start -a android.intent.action.VIEW -d "io.element.android:/?state=IFF1UETGye2ZA8pO\\&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"

19
tools/adb/print_device_state.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
echo " => Device state"
set -x
adb shell dumpsys deviceidle get light
adb shell dumpsys deviceidle get deep
adb shell dumpsys deviceidle get force
adb shell dumpsys deviceidle get screen
adb shell dumpsys deviceidle get charging
adb shell dumpsys deviceidle get network

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
#######################################################################################################################
# Search forbidden pattern
#######################################################################################################################
searchForbiddenStringsScript=./tmp/search_forbidden_strings.pl
if [[ -f ${searchForbiddenStringsScript} ]]; then
echo "${searchForbiddenStringsScript} already there"
else
mkdir tmp
echo "Get the script"
wget https://raw.githubusercontent.com/matrix-org/matrix-dev-tools/develop/bin/search_forbidden_strings.pl -O ${searchForbiddenStringsScript}
fi
if [[ -x ${searchForbiddenStringsScript} ]]; then
echo "${searchForbiddenStringsScript} is already executable"
else
echo "Make the script executable"
chmod u+x ${searchForbiddenStringsScript}
fi
echo
echo "Search for forbidden patterns in Kotlin source files..."
# list all Kotlin folders of the project.
allKotlinDirs=$(find . -type d |grep -v build |grep -v \.git |grep -v \.gradle |grep kotlin$)
${searchForbiddenStringsScript} ./tools/check/forbidden_strings_in_code.txt "$allKotlinDirs"
resultForbiddenStringInCode=$?
echo
echo "Search for forbidden patterns in XML resource files..."
# list all res folders of the project.
allResDirs=$(find . -type d |grep -v build |grep -v \.git |grep -v \.gradle |grep /res$)
${searchForbiddenStringsScript} ./tools/check/forbidden_strings_in_xml.txt "$allResDirs"
resultForbiddenStringInXml=$?
if [[ ${resultForbiddenStringInCode} -eq 0 ]] \
&& [[ ${resultForbiddenStringInXml} -eq 0 ]]; then
echo "OK"
else
echo "❌ ERROR, please check the logs above."
exit 1
fi

View File

@@ -0,0 +1,128 @@
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# This file list String which are not allowed in source code.
# Use Perl regex to write forbidden strings
# Note: line cannot start with a space. Use \s instead.
# It is possible to specify an authorized number of occurrence with === suffix. Default is 0
# Example:
# AuthorizedStringThreeTimes===3
# Extension:kt
### No import static: use full class name
import static
### Rubbish from merge. Please delete those lines (sometimes in comment)
<<<<<<<
>>>>>>>
### carry return before "}". Please remove empty lines.
\n\s*\n\s*\}
### typo detected.
formated
abtract
Succes[^s]
succes[^s]
### Use int instead of Integer
protected Integer
### Use the interface declaration. Example: use type "Map" instead of type "HashMap" to declare variable or parameter. For Kotlin, use mapOf, setOf, ...
(private|public|protected| ) (static )?(final )?(HashMap|HashSet|ArrayList)<
### Use int instead of short
Short\.parseShort
\(short\)
private short
final short
### Line length is limited to 160 chars. Please split long lines
#[^─]{161}
### "DO NOT COMMIT" has been committed
DO NOT COMMIT
### invalid formatting
\s{8}/\*\n \*
# Now checked by ktlint
# [^\w]if\(
# while\(
# for\(
# Add space after //
# DISABLED To re-enable when code will be formatted globally
#^\s*//[^\s]
### invalid formatting (too many space char)
^ /\*
### unnecessary parenthesis around numbers, example: " (0)"
\(\d+\)[^"]
### import the package, do not use long class name with package
android\.os\.Build\.
### Tab char is forbidden. Use only spaces
\t
# Empty lines and trailing space
# DISABLED To re-enable when code will be formatted globally
#[ ]$
### Deprecated, use retrofit2.HttpException
import retrofit2\.adapter\.rxjava\.HttpException
### This is generally not necessary, no need to reset the padding if there is no drawable
setCompoundDrawablePadding\(0\)
# Change thread with Rx
# DISABLED
#runOnUiThread
### Bad formatting of chain (missing new line)
\w\.flatMap\(
### In Kotlin, Void has to be null safe, i.e. use 'Void?' instead of 'Void'
\: Void\)
### Kotlin conversion tools introduce this, but is can be replace by trim()
trim \{ it \<\= \' \' \}
### Put the operator at the beginning of next line
==$
### Use JsonUtils.getBasicGson()
new Gson\(\)
### Use TextUtils.formatFileSize
Formatter\.formatFileSize===1
### Use TextUtils.formatFileSize with short format param to true
Formatter\.formatShortFileSize===1
### Use `Context#getSystemService` extension function provided by `core-ktx`
getSystemService\(Context
### Use DefaultSharedPreferences.getInstance() instead for better performance
PreferenceManager\.getDefaultSharedPreferences==2
### Use the Clock interface, or use `measureTimeMillis`
System\.currentTimeMillis\(\)===1
### Remove extra space between the name and the description
\* @\w+ \w+ +
### Suspicious String template. Please check that the string template will behave as expected, i.e. the class field and not the whole object will be used. For instance `Timber.d("$event.type")` is not correct, you should write `Timber.d("${event.type}")`. In the former the whole event content will be logged, since it's a data class.
Timber.*\$[a-zA-Z_]\w*\??\.[a-zA-Z_]
### Use `import io.element.android.libraries.ui.strings.CommonStrings` then `CommonStrings.<stringKey>` instead
import io\.element\.android\.libraries\.ui\.strings\.R
# Accessibility
### Use string resource for `contentDescription`, or null instead of empty string
contentDescription = "

View File

@@ -0,0 +1,28 @@
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# This file list String which are not allowed in resource.
# Use Perl regex to write forbidden strings
# Note: line cannot start with a space. Use \s instead.
# It is possible to specify an authorized number of occurrence with === suffix. Default is 0
# Example:
# AuthorizedStringThreeTimes===3
# Extension:xml
### Empty tag detected. Empty translation or plurals?
"></
">""</
### Rubbish from merge. Please delete those lines (sometimes in comment)
<<<<<<<
>>>>>>>
### "DO NOT COMMIT" has been committed
DO NOT COMMIT
### Tab char is forbidden. Use only spaces
\t

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
set -e
# Build the project with compose report
echo "Building the project with compose report..."
./gradlew assembleGplayDebug -PcomposeCompilerReports=true -PcomposeCompilerMetrics=true --stacktrace
echo "Checking stability of State classes..."
# Using the find command, list all the files ending with -classes.txt
find . -type f -name "*-classes.txt" | while read -r file; do
# echo "Processing $file"
# Check that there is no line containing "unstable class .*State {"
if grep -E 'unstable class .*State \{' "$file"; then
echo "❌ ERROR: Found unstable State class in $file"
exit 1
fi
done

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
files = [
"ic_compound_arrow_left.xml",
"ic_compound_arrow_right.xml",
"ic_compound_arrow_up_right.xml",
"ic_compound_attachment.xml",
"ic_compound_block.xml",
"ic_compound_chart.xml",
"ic_compound_chat.xml",
"ic_compound_chat_new.xml",
"ic_compound_chat_problem.xml",
"ic_compound_chat_solid.xml",
"ic_compound_chevron_left.xml",
"ic_compound_chevron_right.xml",
"ic_compound_cloud.xml",
"ic_compound_cloud_solid.xml",
"ic_compound_collapse.xml",
"ic_compound_company.xml",
"ic_compound_compose.xml",
"ic_compound_copy.xml",
"ic_compound_dark_mode.xml",
"ic_compound_devices.xml",
"ic_compound_document.xml",
"ic_compound_earpiece.xml",
"ic_compound_edit.xml",
"ic_compound_edit_solid.xml",
"ic_compound_expand.xml",
"ic_compound_extensions.xml",
"ic_compound_extensions_solid.xml",
"ic_compound_file_error.xml",
"ic_compound_files.xml",
"ic_compound_forward.xml",
"ic_compound_guest.xml",
"ic_compound_history.xml",
"ic_compound_host.xml",
"ic_compound_image.xml",
"ic_compound_image_error.xml",
"ic_compound_indent_decrease.xml",
"ic_compound_indent_increase.xml",
"ic_compound_italic.xml",
"ic_compound_key.xml",
"ic_compound_key_off.xml",
"ic_compound_key_off_solid.xml",
"ic_compound_key_solid.xml",
"ic_compound_leave.xml",
"ic_compound_link.xml",
"ic_compound_list_bulleted.xml",
"ic_compound_lock_off.xml",
"ic_compound_mark_as_unread.xml",
"ic_compound_mark_threads_as_read.xml",
"ic_compound_marker_read_receipts.xml",
"ic_compound_mic_off.xml",
"ic_compound_mic_off_solid.xml",
"ic_compound_notifications_off.xml",
"ic_compound_notifications_off_solid.xml",
"ic_compound_offline.xml",
"ic_compound_play.xml",
"ic_compound_play_solid.xml",
"ic_compound_polls.xml",
"ic_compound_polls_end.xml",
"ic_compound_pop_out.xml",
"ic_compound_qr_code.xml",
"ic_compound_quote.xml",
"ic_compound_reaction_add.xml",
"ic_compound_reply.xml",
"ic_compound_restart.xml",
"ic_compound_room.xml",
"ic_compound_search.xml",
"ic_compound_send.xml",
"ic_compound_send_solid.xml",
"ic_compound_share_android.xml",
"ic_compound_sidebar.xml",
"ic_compound_sign_out.xml",
"ic_compound_spinner.xml",
"ic_compound_spotlight.xml",
"ic_compound_switch_camera_solid.xml",
"ic_compound_threads.xml",
"ic_compound_threads_solid.xml",
"ic_compound_unknown.xml",
"ic_compound_unknown_solid.xml",
"ic_compound_unpin.xml",
"ic_compound_user_add.xml",
"ic_compound_user_add_solid.xml",
"ic_compound_video_call.xml",
"ic_compound_video_call_declined_solid.xml",
"ic_compound_video_call_missed_solid.xml",
"ic_compound_video_call_off.xml",
"ic_compound_video_call_off_solid.xml",
"ic_compound_video_call_solid.xml",
"ic_compound_visibility_off.xml",
"ic_compound_voice_call.xml",
"ic_compound_voice_call_solid.xml",
"ic_compound_volume_off.xml",
"ic_compound_volume_off_solid.xml",
"ic_compound_volume_on.xml",
"ic_compound_volume_on_solid.xml",
]
def main():
for file in files:
# Open file for read
with open("./libraries/compound/src/main/res/drawable/" + file, 'r') as f:
data = f.read().split("\n")
# Open file to write
with open("./libraries/compound/src/main/res/drawable/" + file, 'w') as f:
# Write new data
# write the 3 first lines in data
for i in range(3):
f.write(data[i] + "\n")
f.write(" android:autoMirrored=\"true\"\n")
# write the rest of the data
for i in range(3, len(data) - 1):
f.write(data[i] + "\n")
print("Added autoMirrored to " + str(len(files)) + " files.")
if __name__ == "__main__":
main()

45
tools/compound/import_tokens.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Copyright (c) 2025 Element Creations Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
set -e
BRANCH='main'
while getopts b: flag
do
case "${flag}" in
b) BRANCH=${OPTARG};;
*) echo "usage: $0 [-b branch]" >&2
exit 1 ;;
esac
done
echo "Branch used: $BRANCH"
echo "Cloning the compound-design-tokens repository..."
if [ -d tmpCompound ]; then
echo "Deleting tmpCompound folder..."
rm -rf tmpCompound
fi
mkdir tmpCompound
pushd tmpCompound
git clone --branch "${BRANCH}" https://github.com/element-hq/compound-design-tokens
echo "Copying files from tokens repository..."
rm -R ../libraries/compound/src/main/res/drawable
cp -R compound-design-tokens/assets/android/res/drawable ../libraries/compound/src/main/res/
cp -R compound-design-tokens/assets/android/src/* ../libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/
cp -R compound-design-tokens/assets/android/res/theme.iife.js ../libraries/compound/src/main/assets/theme.iife.js
popd
echo "Adding autoMirrored attribute..."
python3 ./tools/compound/addAutoMirrored.py
echo "Removing temporary files..."
rm -rf tmpCompound
echo "Done!"

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
import { schedule } from 'danger'
/**
* Ref and documentation: https://github.com/damian-burke/danger-plugin-lint-report
* This file will check all the error in XML Checkstyle format.
* It covers, lint, ktlint, and detekt errors
*/
const reporter = require("danger-plugin-lint-report")
schedule(reporter.scan({
/**
* File mask used to find XML checkstyle reports.
*/
fileMask: "**/reports/**/**.xml",
/**
* If set to true, the severity will be used to switch between the different message formats (message, warn, fail).
*/
reportSeverity: true,
/**
* If set to true, only issues will be reported that are contained in the current changeset (line comparison).
* If set to false, all issues that are in modified files will be reported.
*/
requireLineModification: false,
/**
* Optional: Sets a prefix foreach violation message.
* This can be useful if there are multiple reports being parsed to make them distinguishable.
*/
// outputPrefix?: ""
/**
* Optional: If set to true, it will remove duplicate violations.
*/
removeDuplicates: true,
}))

121
tools/danger/dangerfile.js Normal file
View File

@@ -0,0 +1,121 @@
/**
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
const {danger, warn} = require('danger')
const fs = require('fs')
const path = require('path')
/**
* Note: if you update the checks in this file, please also update the file ./docs/danger.md
*/
// Useful to see what we got in danger object
// warn(JSON.stringify(danger))
const pr = danger.github.pr
const github = danger.github
// User who has created the PR.
const user = pr.user.login
const modified = danger.git.modified_files
const created = danger.git.created_files
const editedFiles = [...modified, ...created]
// Check that the PR has a description
if (pr.body.length == 0) {
warn("Please provide a description for this PR.")
}
// Warn when there is a big PR
if (editedFiles.length > 50) {
message("This pull request seems relatively large. Please consider splitting it into multiple smaller ones.")
}
// Request a correct title for each PR
if (pr.title.endsWith("…")) {
fail("Please provide a complete title that can be used as a changelog entry.")
}
// Request a `PR-` label for each PR
if (pr.labels.filter((label) => label.name.startsWith("PR-")).length != 1) {
fail("Please add a `PR-` label to categorise the changelog entry.")
}
// check that frozen classes have not been modified
const frozenClasses = [
]
frozenClasses.forEach(frozen => {
if (editedFiles.some(file => file.endsWith(frozen))) {
fail("Frozen class `" + frozen + "` has been modified. Please do not modify frozen class.")
}
}
)
const previewAnnotations = [
'androidx.compose.ui.tooling.preview.Preview',
'io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight',
'io.element.android.libraries.designsystem.preview.PreviewsDayNight'
]
const filesWithPreviews = editedFiles.filter(file => file.endsWith(".kt")).filter(file => {
const content = fs.readFileSync(file);
return previewAnnotations.some((ann) => content.includes("import " + ann));
})
const composablePreviewProviderContents = fs.readFileSync('tests/uitests/src/test/kotlin/base/ComposablePreviewProvider.kt');
const packageTreesRegex = /private val PACKAGE_TREES = arrayOf\(([\w\W]+?)\n\)/gm;
const packageTreesMatch = packageTreesRegex.exec(composablePreviewProviderContents)[1];
const scannedPreviewPackageTrees = packageTreesMatch
.replaceAll("\"", "")
.replaceAll(",", "")
.split('\n').map((line) => line.trim())
.filter((line) => line.length > 0);
const previewPackagesNotIncludedInScreenshotTests = filesWithPreviews.map((file) => {
const content = fs.readFileSync(file);
const packageRegex = /package\s+([a-zA-Z0-9.]+)/;
const packageMatch = packageRegex.exec(content);
if (!packageMatch || packageMatch.length != 2) {
return null;
}
return packageMatch[1];
}).filter((package) => {
if (!package) {
return false;
}
if (!scannedPreviewPackageTrees.some((prefix) => package.includes(prefix))) {
return true;
}
});
if (previewPackagesNotIncludedInScreenshotTests.length > 0) {
const packagesList = previewPackagesNotIncludedInScreenshotTests.map((p) => '- `' + p + '`').join("\n");
warn("You have made changes to a file containing a `@Preview` annotated function but its package name prefix is not included in the `ComposablePreviewProvider`.\nPackages missing in `tests/uitests/src/test/kotlin/base/ComposablePreviewProvider.kt`: \n" + packagesList);
}
// Check for pngs on resources
const hasPngs = editedFiles.filter(file => {
file.toLowerCase().endsWith(".png") && !file.includes("snapshots/images/") // Exclude screenshots
}).length > 0
if (hasPngs) {
warn("You seem to have made changes to some images. Please consider using an vector drawable.")
}
// Check that translations have not been modified by developers
const translationAllowList = [
"ElementBot",
]
if (!translationAllowList.includes(user)) {
if (editedFiles.some(file => file.endsWith("translations.xml"))) {
fail("Some translation files have been edited. Only user `ElementBot` (i.e. translations coming from Localazy) is allowed to do that.\nPlease read more about translations management [in the doc](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#strings).")
}
}

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import os
import subprocess
def getProjectDependencies():
print("=> Computing dependencies...")
command = subprocess.run(
["./gradlew :app:dependencies"],
shell=True,
capture_output=True,
text=True,
)
data = command.stdout
# Remove the trailing info like "(*)"
result = list(map(lambda x: x.split(" (")[0], data.split("\n")))
# Filter out comment line
result = list(filter(lambda x: "--- project" in x, result))
return result
def checkThatModulesExist(dependencies):
print("=> Checking that all modules exist...")
error = 0
modules = set()
for line in dependencies:
if line:
line = line.split(" ")
for elem in line:
if ":" in elem:
modules.add(elem)
for module in modules:
path = "." + module.replace(":", "/") + "/build.gradle.kts"
if not os.path.exists(path):
error += 1
print("Error: there is at least one dependency to '" + module + "' but the module does not exist.")
print(" Please remove occurrence(s) of 'implementation(projects" + module.replace(":", ".") + ")'.")
return error
def checkThatThereIsNoTestDependency(dependencies):
print("=> Checking that there are no test dependencies...")
errors = set()
currentProject = ""
for line in dependencies:
if line.startswith("+--- project "):
currentProject = line.split(" ")[2]
else:
if ":test" in currentProject:
continue
else:
subProject = line.split(" ")[-1]
if subProject.endswith(":test") or ":tests:" in subProject and "detekt-rules" not in subProject:
error = "Error: '" + currentProject + "' depends on the test project '" + subProject + "'\n"
error += " Please replace occurrence(s) of 'implementation(projects" + subProject.replace(":", ".") + ")'"
error += " with 'testImplementation(projects" + subProject.replace(":", ".") + ")'."
errors.add(error)
for error in errors:
print(error)
return len(errors)
def main():
dependencies = getProjectDependencies()
# for dep in dependencies:
# print(dep)
errors = 0
errors += checkThatModulesExist(dependencies)
errors += checkThatThereIsNoTestDependency(dependencies)
print()
if (errors == 0):
print("All checks passed successfully.")
elif (errors == 1):
print("Please fix the error above.")
else:
print("Please fix the " + str(errors) + " errors above.")
exit(errors)
if __name__ == "__main__":
main()

275
tools/detekt/detekt.yml Normal file
View File

@@ -0,0 +1,275 @@
# Copyright (c) 2025 Element Creations Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Default rules: https://github.com/detekt/detekt/blob/main/detekt-core/src/main/resources/default-detekt-config.yml
style:
AlsoCouldBeApply:
active: true
BracesOnWhenStatements:
active: false
CascadingCallWrapping:
active: true
includeElvis: true
DataClassShouldBeImmutable:
active: true
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: true
ExplicitCollectionElementAccessMethod:
active: true
ExplicitItLambdaParameter:
active: true
MaxLineLength:
# Default is 120
maxLineLength: 160
MagicNumber:
active: false
ReturnCount:
active: false
UnnecessaryAbstractClass:
active: true
FunctionOnlyReturningConstant:
active: false
UnusedPrivateMember:
active: true
DestructuringDeclarationWithTooManyEntries:
active: true
maxDestructuringEntries: 5
UnusedParameter:
active: true
UnnecessaryInnerClass:
active: true
UnnecessaryLet:
active: true
UnnecessaryParentheses:
active: true
allowForUnclearPrecedence: false
UntilInsteadOfRangeTo:
active: true
UnusedImports:
active: true
UnusedPrivateProperty:
active: true
ThrowsCount:
active: false
LoopWithTooManyJumpStatements:
active: true
SerialVersionUIDInSerializableClass:
active: false
ProtectedMemberInFinalClass:
active: true
UseCheckOrError:
active: true
OptionalUnit:
active: true
PreferToOverPairSyntax:
active: true
RedundantExplicitType:
active: true
TrailingWhitespace:
active: true
TrimMultilineRawString:
active: true
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
UnderscoresInNumericLiterals:
active: true
acceptableLength: 4
allowNonStandardGrouping: false
UnnecessaryAnnotationUseSiteTarget:
active: true
UnnecessaryBackticks:
active: true
UnnecessaryBracesAroundTrailingLambda:
active: true
UseDataClass:
active: true
allowVars: false
UseEmptyCounterpart:
active: true
UseIfEmptyOrIfBlank:
active: true
UseLet:
active: true
UseSumOfInsteadOfFlatMapSize:
active: true
coroutines:
GlobalCoroutineUsage:
# Keep false for now.
active: false
SuspendFunSwallowedCancellation:
active: true
SuspendFunWithCoroutineScopeReceiver:
active: true
empty-blocks:
EmptyFunctionBlock:
active: false
EmptySecondaryConstructor:
active: true
potential-bugs:
ImplicitDefaultLocale:
active: true
CastNullableToNonNullableType:
active: true
CastToNullableType:
active: true
Deprecation:
active: true
DontDowncastCollectionTypes:
active: true
ElseCaseInsteadOfExhaustiveWhen:
active: true
ExitOutsideMain:
active: true
ImplicitUnitReturnType:
active: true
allowExplicitReturnType: false
MissingPackageDeclaration:
active: true
excludes: [ '**/*.kts' ]
NullCheckOnMutableProperty:
active: true
NullableToStringCall:
active: true
PropertyUsedBeforeDeclaration:
active: true
UnconditionalJumpStatementInLoop:
active: true
UnnecessaryNotNullCheck:
active: true
exceptions:
TooGenericExceptionCaught:
active: false
SwallowedException:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: true
TooGenericExceptionThrown:
active: true
InstanceOfCheckForException:
active: true
ObjectExtendsThrowable:
active: true
complexity:
TooManyFunctions:
active: false
LongMethod:
active: false
LongParameterList:
active: false
CyclomaticComplexMethod:
active: false
NestedBlockDepth:
active: false
ComplexCondition:
active: true
LargeClass:
active: true
naming:
VariableNaming:
active: true
TopLevelPropertyNaming:
active: true
FunctionNaming:
active: true
ignoreAnnotated: [ 'Composable' ]
LambdaParameterNaming:
active: true
NonBooleanPropertyPrefixedWithIs:
active: true
VariableMaxLength:
active: true
performance:
SpreadOperator:
active: false
CouldBeSequence:
active: true
UnnecessaryPartOfBinaryExpression:
active: true
# Note: all rules for `comments` are disabled by default, but I put them here to be aware of their existence
comments:
AbsentOrWrongFileLicense:
active: false
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
DeprecatedBlockTag:
active: true
EndOfSentenceFormat:
active: true
OutdatedDocumentation:
active: true
allowParamOnConstructorProperties: true
UndocumentedPublicClass:
active: false
UndocumentedPublicFunction:
active: false
UndocumentedPublicProperty:
active: false
Compose:
CompositionLocalAllowlist:
active: true
# You can optionally define a list of CompositionLocals that are allowed here
allowedCompositionLocals:
- LocalCompoundColors
- LocalSnackbarDispatcher
- LocalCameraPositionState
- LocalMediaItemPresenterFactories
- LocalTimelineItemPresenterFactories
- LocalRoomMemberProfilesCache
- LocalMentionSpanUpdater
- LocalAnalyticsService
- LocalBuildMeta
- LocalUiTestMode
- LocalSdkIntVersionProvider
CompositionLocalNaming:
active: true
ContentEmitterReturningValues:
active: true
# You can optionally add your own composables here
# contentEmitters: MyComposable,MyOtherComposable
ModifierComposable:
active: true
ModifierMissing:
active: true
ModifierReused:
active: true
ModifierWithoutDefault:
active: true
MultipleEmitters:
active: true
# You can optionally add your own composables here
# contentEmitters: MyComposable,MyOtherComposable
MutableParams:
active: true
ComposableNaming:
active: true
# You can optionally disable the checks in this rule for regex matches against the composable name (e.g. molecule presenters)
# allowedComposableFunctionNames: .*Presenter,.*MoleculePresenter
ComposableParamOrder:
active: true
PreviewAnnotationNaming:
active: true
PreviewPublic:
active: true
# You can optionally disable that only previews with @PreviewParameter are flagged
previewPublicOnlyIfParams: false
RememberMissing:
active: true
UnstableCollections:
active: true

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2022-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
## Dependency graph https://github.com/savvasdalkitsis/module-dependency-graph
dotPath=$(pwd)/docs/images/module_graph.dot
pngPath=$(pwd)/docs/images/module_graph.png
./gradlew graphModules -PdotFilePath="${dotPath}" -PgraphOutputFilePath="${pngPath}" -PautoOpenGraph=false
rm "${dotPath}"

20
tools/git/validate_lfs.sh Executable file
View File

@@ -0,0 +1,20 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2022-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Based on https://cashapp.github.io/paparazzi/#git-lfs
# Compare the output of `git ls-files ':(attr:filter=lfs)'` against `git lfs ls-files`
# If there's no diff we assume the files have been committed using git lfs
diff <(git ls-files ':(attr:filter=lfs)' | sort) <(git lfs ls-files -n | sort) >/dev/null
ret=$?
if [[ $ret -ne 0 ]]; then
echo >&2 "Detected files committed without using Git LFS."
echo >&2 "Install git lfs (eg brew install git-lfs) and run 'git lfs install --local' within the root repository directory and re-commit your files."
exit 1
fi

10
tools/gitflow/gitflow-init.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
git flow init -d
git config gitflow.prefix.versiontag v

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2022-2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
#
import argparse
import hashlib
import json
import os
# Run `pip3 install requests --break-system-packages` if not installed yet
import requests
# Run `pip3 install re` if not installed yet
import re
import time
# This script downloads artifacts from GitHub.
# Ref: https://docs.github.com/en/rest/actions/artifacts#get-an-artifact
error = False
### Arguments
parser = argparse.ArgumentParser(description='Download artifacts from GitHub.')
parser.add_argument('-t',
'--token',
required=True,
help='The GitHub token with read access.')
parser.add_argument('-r',
'--runUrl',
required=True,
help='the GitHub action run url.')
parser.add_argument('-d',
'--directory',
default="",
help='the target directory, where files will be downloaded. If not provided the runId will be used to create a directory.')
parser.add_argument('-v',
'--verbose',
help="increase output verbosity.",
action="store_true")
parser.add_argument('-s',
'--simulate',
help="simulate action, do not create folder or download any file.",
action="store_true")
args = parser.parse_args()
if args.verbose:
print("Argument:")
print(args)
# Split the artifact URL to get information
# Ex: https://github.com/element-hq/element-x-android/actions/runs/9065756777
runUrl = args.runUrl
url_regex = r"https://github.com/(.+?)/(.+?)/actions/runs/(.+)"
result = re.search(url_regex, runUrl)
if result is None:
print(
"❌ Invalid parameter --runUrl '%s'. Please check the format.\nIt should be something like: %s" %
(runUrl, 'https://github.com/element-hq/element-x-android/actions/runs/9065756777')
)
exit(1)
(gitHubRepoOwner, gitHubRepo, runId) = result.groups()
if args.verbose:
print("gitHubRepoOwner: %s, gitHubRepo: %s, runId: %s" % (gitHubRepoOwner, gitHubRepo, runId))
headers = {
'Authorization': "Bearer %s" % args.token,
'Accept': 'application/vnd.github+json'
}
base_url = "https://api.github.com/repos/%s/%s/actions/runs/%s" % (gitHubRepoOwner, gitHubRepo, runId)
### Fetch build state
status = ""
data = "{}"
while status != "completed":
r = requests.get(base_url, headers=headers)
data = json.loads(r.content.decode())
if args.verbose:
print("Json data:")
print(data)
status = data.get("status")
if data.get("status") == "completed":
if data.get("conclusion") != "success":
print("❌ The action %s is completed, but there is an error, the conclusion is: %s." % (runUrl, data.get("conclusion")))
exit(1)
else:
# Wait 1 minute
print("The action %s is not completed yet, waiting 1 minute..." % runUrl)
time.sleep(60)
artifacts_url = data.get("artifacts_url")
if args.verbose:
print("Artifacts url: %s" % artifacts_url)
r = requests.get(artifacts_url, headers=headers)
data = json.loads(r.content.decode())
if args.directory == "":
targetDir = runId
else:
targetDir = args.directory
for artifact in data.get("artifacts"):
if args.verbose:
print("Artifact:")
print(artifact)
# Invoke the script to download the artifact
os.system("python3 ./tools/github/download_github_artifacts.py -t %s -d %s -a %s" % (args.token, targetDir, artifact.get("url")))

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2022-2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
#
import argparse
import hashlib
import json
import os
# Run `pip3 install requests` if not installed yet
import requests
# Run `pip3 install re` if not installed yet
import re
# This script downloads artifacts from GitHub.
# Ref: https://docs.github.com/en/rest/actions/artifacts#get-an-artifact
error = False
### Arguments
parser = argparse.ArgumentParser(description='Download artifacts from GitHub.')
parser.add_argument('-t',
'--token',
required=True,
help='The GitHub token with read access.')
parser.add_argument('-a',
'--artifactUrl',
required=True,
help='the artifact_url from GitHub.')
parser.add_argument('-f',
'--filename',
help='the filename, if not provided, will use the artifact name.')
parser.add_argument('-i',
'--ignoreErrors',
help='Ignore errors that can be ignored. Build state and number of artifacts.',
action="store_true")
parser.add_argument('-d',
'--directory',
default="",
help='the target directory, where files will be downloaded. If not provided the artifactId will be used to create a directory.')
parser.add_argument('-v',
'--verbose',
help="increase output verbosity.",
action="store_true")
parser.add_argument('-s',
'--simulate',
help="simulate action, do not create folder or download any file.",
action="store_true")
args = parser.parse_args()
if args.verbose:
print("Argument:")
print(args)
# Split the artifact URL to get information
# Ex: https://github.com/element-hq/element-x-android/actions/runs/7299827320/artifacts/1131077517
artifactUrl = args.artifactUrl
# if artifactUrl starts with https://github.com
if artifactUrl.startswith('https://github.com'):
url_regex = r"https://github.com/(.+?)/(.+?)/actions/runs/.+?/artifacts/(.+)"
result = re.search(url_regex, artifactUrl)
if result is None:
print(
"❌ Invalid parameter --artifactUrl '%s'. Please check the format.\nIt should be something like: %s" %
(artifactUrl, 'https://github.com/element-hq/element-x-android/actions/runs/7299827320/artifacts/1131077517')
)
exit(1)
else:
url_regex = r"https://api.github.com/repos/(.+?)/(.+?)/actions/artifacts/(.+)"
result = re.search(url_regex, artifactUrl)
if result is None:
print(
"❌ Invalid parameter --artifactUrl '%s'. Please check the format.\nIt should be something like: %s" %
(artifactUrl, 'https://api.github.com/repos/element-hq/element-x-android/actions/artifacts/1131077517')
)
exit(1)
(gitHubRepoOwner, gitHubRepo, artifactId) = result.groups()
if args.verbose:
print("gitHubRepoOwner: %s, gitHubRepo: %s, artifactId: %s" % (gitHubRepoOwner, gitHubRepo, artifactId))
headers = {
'Authorization': "Bearer %s" % args.token,
'Accept': 'application/vnd.github+json'
}
base_url = "https://api.github.com/repos/%s/%s/actions/artifacts/%s" % (gitHubRepoOwner, gitHubRepo, artifactId)
### Fetch build state
print("Getting artifacts data of project '%s/%s' artifactId '%s'..." % (gitHubRepoOwner, gitHubRepo, artifactId))
if args.verbose:
print("Url: %s" % base_url)
r = requests.get(base_url, headers=headers)
data = json.loads(r.content.decode())
if args.verbose:
print("Json data:")
print(data)
if args.verbose:
print("Create subfolder %s to download artifacts..." % artifactId)
if args.directory == "":
targetDir = artifactId
else:
targetDir = args.directory
if not args.simulate:
os.makedirs(targetDir, exist_ok=True)
url = data.get("archive_download_url")
if args.filename is not None:
filename = args.filename
else:
filename = data.get("name") + ".zip"
## Print some info about the artifact origin
commitLink = "https://github.com/%s/%s/commit/%s" % (gitHubRepoOwner, gitHubRepo, data.get("workflow_run").get("head_sha"))
print("Preparing to download artifact `%s`, built from branch: `%s` (commit %s)" % (data.get("name"), data.get("workflow_run").get("head_branch"), commitLink))
if args.verbose:
print()
print("Artifact url: %s" % url)
target = targetDir + "/" + filename
sizeInBytes = data.get("size_in_bytes")
print("Downloading %s to '%s' (file size is %s bytes, this may take a while)..." % (filename, targetDir, sizeInBytes))
if not args.simulate:
# open file to write in binary mode
with open(target, "wb") as file:
# get request
response = requests.get(url, headers=headers)
# write to file
file.write(response.content)
print("Verifying file size...")
# get the file size
size = os.path.getsize(target)
if sizeInBytes != size:
# error = True
print("Warning, file size mismatch: expecting %s and get %s. This is just a warning for now..." % (sizeInBytes, size))
if error:
print("❌ Error(s) occurred, please check the log")
exit(1)
else:
print("Done!")

126
tools/lint/lint.xml Normal file
View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2022 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<lint>
<!-- Ensure this file does not contain unknown Ids -->
<issue id="UnknownIssueId" severity="warning" />
<!-- Modify some severity -->
<!-- Resource -->
<issue id="MissingTranslation" severity="ignore" />
<issue id="TypographyEllipsis" severity="error" />
<issue id="ImpliedQuantity" severity="error" />
<issue id="MissingQuantity" severity="warning" />
<issue id="UnusedQuantity" severity="error" />
<issue id="IconXmlAndPng" severity="error" />
<issue id="IconDipSize" severity="error" />
<issue id="IconDuplicatesConfig" severity="error" />
<issue id="IconDuplicates" severity="error" />
<issue id="IconExpectedSize" severity="error">
<ignore path="**/ic_launcher_monochrome.webp" />
</issue>
<issue id="LocaleFolder" severity="error" />
<!-- AlwaysShowAction is considered as an error to force ignoring the issue when detected -->
<issue id="AlwaysShowAction" severity="error" />
<issue id="TooManyViews" severity="warning">
<!-- Ignore TooManyViews in debug build type -->
<ignore path="**/src/debug/**" />
</issue>
<issue id="UnusedResources" severity="error">
<!-- Ignore unused strings resource from localazy -->
<ignore path="**/localazy.xml" />
<!-- Ignore unused resource in debug build type -->
<ignore path="**/src/debug/**" />
<!-- Ignore unused resources in designsystem since they're imported elsewhere through aliases and can't be properly detected -->
<ignore path="**/libraries/designsystem/src/main/res/**" />
</issue>
<!-- UX -->
<issue id="ButtonOrder" severity="error" />
<issue id="TextFields" severity="error" />
<!-- Accessibility -->
<issue id="LabelFor" severity="error" />
<issue id="ContentDescription" severity="error" />
<issue id="SpUsage" severity="error" />
<!-- Layout -->
<issue id="UnknownIdInLayout" severity="error" />
<issue id="StringFormatCount" severity="error" />
<issue id="HardcodedText" severity="error" />
<issue id="ObsoleteLayoutParam" severity="error" />
<issue id="InefficientWeight" severity="error" />
<issue id="DisableBaselineAlignment" severity="error" />
<issue id="ScrollViewSize" severity="error" />
<issue id="NegativeMargin" severity="error" />
<!-- RTL -->
<issue id="RtlEnabled" severity="error" />
<issue id="RtlHardcoded" severity="error" />
<issue id="RtlSymmetry" severity="error" />
<!-- Code -->
<issue id="NewApi" severity="error" />
<issue id="SetTextI18n" severity="error" />
<issue id="ViewConstructor" severity="error" />
<issue id="UseValueOf" severity="error" />
<issue id="ObsoleteSdkInt" severity="error" />
<issue id="Recycle" severity="error" />
<issue id="KotlinPropertyAccess" severity="error" />
<issue id="DefaultLocale" severity="error" />
<issue id="CheckResult" severity="error" />
<issue id="StaticFieldLeak" severity="error" />
<issue id="InvalidPackage">
<!-- Ignore error from HtmlCompressor lib -->
<ignore path="**/htmlcompressor-1.4.jar" />
<!-- Ignore error from dropbox-core-sdk-3.0.8 lib, which comes with Jitsi library -->
<ignore path="**/dropbox-core-sdk-3.0.8.jar" />
</issue>
<!-- Manifest -->
<issue id="PermissionImpliesUnsupportedChromeOsHardware" severity="error" />
<issue id="DataExtractionRules" severity="error" />
<!-- Performance -->
<issue id="UselessParent" severity="error" />
<!-- Dependencies -->
<issue id="KtxExtensionAvailable" severity="error" />
<!-- Timber -->
<!-- This rule is failing on CI because it's marked as unknwown rule id :/-->
<!-- <issue id="BinaryOperationInTimber" severity="error" />-->
<issue id="LogNotTimber" severity="error" />
<!-- Wording -->
<issue id="Typos" severity="error" />
<issue id="TypographyDashes" severity="error" />
<issue id="PluralsCandidate" severity="error" />
<!-- Notification -->
<issue id="LaunchActivityFromNotification" severity="error" />
<!-- We handle them manually -->
<issue id="EnsureInitializerMetadata" severity="ignore" />
<!-- DI -->
<!-- issue id="JvmStaticProvidesInObjectDetector" severity="error" /-->
<!-- Compose -->
<issue id="UnnecessaryComposedModifier" severity="error" />
<!-- There seems to be an issue with this check, it flags lots of false positives. -->
<!-- See https://issuetracker.google.com/issues/349411310 -->
<!-- TODO: check again in the near future. -->
<issue id="ProduceStateDoesNotAssignValue" severity="ignore" />
</lint>

76
tools/localazy/README.md Normal file
View File

@@ -0,0 +1,76 @@
# Localazy
Localazy is used to host the source strings and their translations.
<!--- TOC -->
* [Localazy project](#localazy-project)
* [Key naming rules](#key-naming-rules)
* [Special suffixes](#special-suffixes)
* [Placeholders](#placeholders)
* [CLI Installation](#cli-installation)
* [Download translations](#download-translations)
* [Add translations to a specific module](#add-translations-to-a-specific-module)
<!--- END -->
## Localazy project
[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element)
To add new strings, or to translate existing strings, go the the Localazy project: [https://localazy.com/p/element](https://localazy.com/p/element). Please follow the key naming rules (see below).
Never edit manually the files `localazy.xml` or `translations.xml`!.
### Key naming rules
For code clarity and in order to download strings to the correct module, here are some naming rules to follow as much as possible:
- Keys for common strings, i.e. strings that can be used at multiple places must start by `action.` if this is a verb, or `common.` if not;
- Keys for common accessibility strings must start by `a11y.`. Example: `a11y.hide_password`;
- Keys for common strings should be named to match the string. Example: `action.copy_link` for the string `Copy link`;
- When creating common strings, make sure to enable "Use dot (.) to create nested keys";
- Keys for strings used in a single screen must start with `screen_` followed by the screen name, followed by a free name. Example: `screen_onboarding_welcome_title`;
- Keys can have `_title` or `_subtitle` suffixes. Example: `screen_onboarding_welcome_title`, `screen_change_server_subtitle`;
- For dialogs, keys can have `_dialog_title`, `_dialog_content`, and `_dialog_submit` suffixes. Example: `screen_signout_confirmation_dialog_title`, `screen_signout_confirmation_dialog_content`, `screen_signout_confirmation_dialog_submit`;
- `a11y.` pattern can be used for strings that are only used for accessibility. Example: `a11y.hide_password`, `screen_roomlist_a11y_create_message`;
- Strings for error message can start by `error_`, or contain `_error_` if used in a specific screen only. Example: `error_some_messages_have_not_been_sent`, `screen_change_server_error_invalid_homeserver`;
*Note*: those rules applies for `strings` and for `plurals`.
#### Special suffixes
- if a key is suffixed by `_ios`, it will not be imported in the Android project;
- if a key is suffixed by `_android`, it will not be imported in the iOS project.
So feel free to use those suffixes when necessary for instance when the string content is referring to something related to Android only, or iOS only.
#### Placeholders
Placeholders should have the form `%1$s`, `%1$d`, etc.. Please use numbered placeholders. Note that Localazy will take care of converting the placeholder to Android (-> `%1$s`) and iOS specific format (-> `%1$@`). Ideally add a comment on Localazy to explain with what the placeholder(s) will be replaced at runtime.
## CLI Installation
To install the Localazy client, follow the instructions from [here](https://localazy.com/docs/cli/installation).
## Download translations
In the root folder of the project, run:
```shell
./tools/localazy/downloadStrings.sh
```
It will update all the `localazy.xml` resource files. In case of merge conflicts, just erase the files and download again using the script.
To also include the translations, i.e. the `translations.xml` files, add `--all` argument:
```shell
./tools/localazy/downloadStrings.sh --all
```
## Add translations to a specific module
Edit the file [config.json](./config.json) to add a new module, or add a new item in `includeRegex` arrays, then run the script again to see the effect.
[generateLocalazyConfig.py](generateLocalazyConfig.py) is the Python script that convert `config.json` to a localazy configuration file. Generally you should not edit this file.

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import re
import sys
from xml.dom import minidom
file = sys.argv[1]
# Dict of forbidden terms, with exceptions for some String name
# Keys are the terms, values are the exceptions.
forbiddenTerms = {
r"\bElement\b": [
# Those 2 strings are only used in debug version
"screen_advanced_settings_element_call_base_url",
"screen_advanced_settings_element_call_base_url_description",
# only used for element.io homeserver, so it's fine
"screen_server_confirmation_message_login_element_dot_io",
# "Be in your element", will probably be changed on the forks, so we can ignore.
"screen_onboarding_welcome_title",
# Contains "Element Call"
"screen_incoming_call_subtitle_android",
"call_invalid_audio_device_bluetooth_devices_disabled",
# Contains "Element X"
"screen_room_timeline_legacy_call",
# We explicitly want to mention Element Pro in these 2:
"screen_change_server_error_element_pro_required_title",
"screen_change_server_error_element_pro_required_message",
]
}
content = minidom.parse(file)
errors = []
### Strings
for elem in content.getElementsByTagName('string'):
name = elem.attributes['name'].value
# Continue if value is empty
child = elem.firstChild
if child is None:
# Should not happen
continue
value = child.nodeValue
# If value contains a forbidden term, add the error to errors
for (term, exceptions) in forbiddenTerms.items():
matches = re.search(term, value)
if matches and name not in exceptions:
errors.append('Forbidden term "' + term + '" in string: "' + name + '": ' + value)
### Plurals
for elem in content.getElementsByTagName('plurals'):
name = elem.attributes['name'].value
for it in elem.childNodes:
if it.nodeType != it.ELEMENT_NODE:
continue
# Continue if value is empty
child = it.firstChild
if child is None:
# Should not happen
continue
value = child.nodeValue
# If value contains a forbidden term, add the error to errors
for (term, exceptions) in forbiddenTerms.items():
matches = re.search(term, value)
if matches and name not in exceptions:
errors.append('Forbidden term "' + term + '" in plural: "' + name + '": ' + value)
# If errors is not empty print the report
if errors:
print('Error(s) in file ' + file + ":", file=sys.stderr)
for error in errors:
print(" - " + error, file=sys.stderr)
sys.exit(1)

389
tools/localazy/config.json Normal file
View File

@@ -0,0 +1,389 @@
{
"modules" : [
{
"name" : ":appnav",
"includeRegex" : [
"banner\\.migrate_to_native_sliding_sync\\.force_logout.title",
"banner\\.migrate_to_native_sliding_sync\\.action",
"banner\\.migrate_to_native_sliding_sync\\.app_force_logout\\.title"
]
},
{
"name" : ":features:rageshake:impl",
"includeRegex" : [
"screen_bug_report_.*"
]
},
{
"name" : ":features:rageshake:api",
"includeRegex" : [
"crash_detection_.*",
"rageshake_detection_.*",
"settings_rageshake.*"
]
},
{
"name" : ":features:announcement:impl",
"includeRegex" : [
"screen\\.space_announcement\\..*"
]
},
{
"name" : ":features:logout:impl",
"includeRegex" : [
"screen_signout_.*"
]
},
{
"name" : ":features:deactivation:impl",
"includeRegex" : [
"screen_deactivate_account_.*"
]
},
{
"name" : ":features:roomaliasresolver:impl",
"includeRegex" : [
"screen_room_alias_resolver_.*",
"screen.join_room.loading_alert_title"
]
},
{
"name" : ":features:signedout:impl",
"includeRegex" : [
"screen_signed_out_.*"
]
},
{
"name" : ":features:invite:impl",
"includeRegex" : [
"screen_invites_.*",
"screen\\.join_room\\.decline_and_block_.*",
"screen\\.decline_and_block\\..*"
]
},
{
"name" : ":features:createroom:impl",
"includeRegex" : [
"screen_create_room_.*",
"screen\\.create_room\\..*"
]
},
{
"name" : ":features:startchat:impl",
"includeRegex" : [
"screen_start_chat_.*",
"screen\\.start_chat\\..*",
"screen_room_directory_search_title",
"screen_create_room_action_create_room"
]
},
{
"name" : ":features:verifysession:impl",
"includeRegex" : [
"screen_session_verification_.*",
"screen_signout_in_progress_dialog_content",
"screen_identity_.*"
]
},
{
"name" : ":libraries:textcomposer:impl",
"includeRegex" : [
"rich_text_editor.*",
".*voice_message_tooltip",
"screen\\.media_upload_preview.caption_warning"
]
},
{
"name" : ":libraries:dateformatter:impl",
"includeRegex" : [
"common\\.date\\..*"
]
},
{
"name" : ":libraries:permissions:api",
"includeRegex" : [
"dialog\\.permission_.*"
]
},
{
"name" : ":libraries:androidutils",
"includeRegex" : [
"error_no_compatible_app_found"
]
},
{
"name" : ":libraries:mediaviewer:impl",
"includeRegex" : [
"screen\\.media_details\\..*",
"screen_media_browser_.*"
]
},
{
"name" : ":libraries:eventformatter:impl",
"includeRegex" : [
"state_event_.*"
]
},
{
"name" : ":libraries:push:impl",
"includeRegex" : [
"push_.*",
"notification_.*",
"notification\\..*",
"troubleshoot_notifications\\.test_blocked_users\\..*",
"troubleshoot_notifications_test_current_push_provider.*",
"troubleshoot_notifications_test_detect_push_provider.*",
"troubleshoot_notifications_test_display_notification_.*",
"troubleshoot_notifications_test_push_loop_back_.*"
]
},
{
"name" : ":libraries:permissions:impl",
"includeRegex" : [
"troubleshoot_notifications_test_check_permission_.*"
]
},
{
"name" : ":libraries:pushproviders:firebase",
"includeRegex" : [
"troubleshoot_notifications_test_firebase_.*"
]
},
{
"name" : ":libraries:pushproviders:unifiedpush",
"includeRegex" : [
"troubleshoot_notifications_test_unified_push_.*"
]
},
{
"name" : ":features:login:impl",
"includeRegex" : [
"screen_onboarding_.*",
"screen_login_.*",
"screen_server_confirmation_.*",
"screen_change_server_.*",
"screen_change_account_provider_.*",
"screen_create_account_.*",
"screen_account_provider_.*",
"screen_waitlist_.*",
"screen_qr_code_login_.*"
]
},
{
"name" : ":features:leaveroom:api",
"includeRegex" : [
"leave_room_alert_.*",
"leave_conversation_alert_.*"
]
},
{
"name" : ":features:home:impl",
"includeRegex" : [
"screen\\.home\\..*",
"screen_roomlist_.*",
"screen\\.roomlist\\..*",
"session_verification_banner_.*",
"confirm_recovery_key_banner_.*",
"banner\\.set_up_recovery\\..*",
"banner\\.battery_optimization\\..*",
"banner\\.new_sound\\..*",
"full_screen_intent_banner_.*",
"screen_migration_.*",
"screen_invites_.*"
]
},
{
"name" : ":features:roomdetails:impl",
"includeRegex" : [
"screen_room_details_.*",
"screen\\.room_details\\..*",
"screen_room_member_list_.*",
"screen\\.room_member_list\\..*",
"screen_room_notification_settings_.*",
"screen_notification_settings_edit_failed_updating_default_mode",
"screen_polls_history_title",
"screen_notification_settings_mentions_only_disclaimer",
"screen_room_change_.*",
"screen_room_roles_.*",
"screen\\.edit_room_address\\..*",
"screen\\.security_and_privacy\\..*"
]
},
{
"name" : ":features:space:impl",
"includeRegex" : [
"screen\\.leave_space\\..*",
"screen\\.space_settings\\..*"
]
},
{
"name" : ":features:userprofile:shared",
"includeRegex" : [
"screen_start_chat_error_starting_chat",
"screen_dm_details_.*",
"screen_room_member_details_.*"
]
},
{
"name" : ":features:invitepeople:impl",
"includeRegex" : [
"screen\\.invite_users\\..*"
]
},
{
"name" : ":features:messages:impl",
"includeRegex" : [
"emoji_picker_category_.*",
"screen_report_content_.*",
"screen_room_attachment.*",
"screen_room_encrypted.*",
"screen_room_invite.*",
"screen\\.room\\.mention.*",
"screen_room_message.*",
"screen_room_retry.*",
"screen_room_timeline.*",
"screen\\.room_timeline.*",
"screen_room_typing.*",
"screen\\.media_upload.*"
]
},
{
"name" : ":features:analytics:impl",
"includeRegex" : [
"screen_analytics_prompt.*"
]
},
{
"name" : ":features:analytics:api",
"includeRegex" : [
"screen_analytics_settings_.*"
]
},
{
"name" : ":features:ftue:impl",
"includeRegex" : [
"screen_welcome_.*",
"screen_notification_optin_.*",
"screen_identity_.*",
"screen_session_verification_enter_recovery_key"
]
},
{
"name" : ":features:poll:impl",
"includeRegex" : [
"screen_create_poll_.*",
"screen_edit_poll_.*",
"screen_polls_history_.*"
]
},
{
"name" : ":features:poll:api",
"includeRegex" : [
"a11y\\.polls\\..*"
]
},
{
"name" : ":features:securebackup:impl",
"includeRegex" : [
"screen_chat_backup_.*",
"screen_key_backup_disable_.*",
"screen_recovery_key_.*",
"screen_create_new_recovery_key_.*",
"screen_encryption_reset.*",
"screen_reset_encryption.*",
"screen\\.reset_encryption.*"
]
},
{
"name" : ":features:preferences:impl",
"includeRegex" : [
"screen_advanced_settings_.*",
"screen\\.advanced_settings\\..*",
"screen_edit_profile_.*",
"screen_notification_settings_.*",
"screen_blocked_users_.*",
"full_screen_intent_banner_.*",
"troubleshoot_notifications_entry_point_.*",
"screen\\.labs\\..*"
]
},
{
"name" : ":libraries:troubleshoot:impl",
"includeRegex" : [
"troubleshoot_notifications_screen_.*",
"screen\\.push_history\\..*"
]
},
{
"name" : ":libraries:matrixui",
"includeRegex" : [
"screen_invites_invited_you",
"screen\\.bottom_sheet\\.create_dm\\..*"
]
},
{
"name" : ":features:call:impl",
"includeRegex" : [
"call_.*",
"screen_incoming_call.*"
]
},
{
"name" : ":features:lockscreen:impl",
"includeRegex" : [
"screen_app_lock_.*",
"screen_signout_in_progress_dialog_content"
]
},
{
"name" : ":features:roomdirectory:impl",
"includeRegex" : [
"screen_room_directory_.*"
]
},
{
"name" : ":features:joinroom:impl",
"includeRegex" : [
"screen_join_room_.*",
"screen\\.join_room\\..*"
]
},
{
"name" : ":features:knockrequests:impl",
"includeRegex" : [
"screen\\.knock_requests_list\\..*",
"screen\\.room\\.single_knock_request.*",
"screen\\.room\\.multiple_knock_requests.*"
]
},
{
"name" : ":features:reportroom:impl",
"includeRegex" : [
"screen\\.report_room\\..*"
]
},
{
"name" : ":features:roommembermoderation:impl",
"includeRegex" : [
"screen\\.bottom_sheet\\.manage_room_member\\..*"
]
},
{
"name" : ":features:rolesandpermissions:impl",
"includeRegex" : [
"screen_room_change_.*",
"screen_room_roles_.*",
"screen\\.room_roles_and_permissions\\..*",
"screen_room_member_list.*",
"screen\\.room_member_list\\..*"
]
},
{
"name" : ":features:securityandprivacy:impl",
"includeRegex" : [
"screen\\.edit_room_address\\..*",
"screen\\.security_and_privacy\\..*"
]
}
]
}

View File

@@ -0,0 +1,48 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
set -e
if [[ $1 == "--all" ]]; then
echo "Note: I will update all the files."
allFiles=1
else
echo "Note: I will update only the English files."
allFiles=0
fi
echo "Generating the configuration file for localazy..."
python3 ./tools/localazy/generateLocalazyConfig.py $allFiles
echo "Deleting all existing localazy.xml files..."
find . -name 'localazy.xml' -delete
if [[ $allFiles == 1 ]]; then
echo "Deleting all existing translations.xml files..."
find . -name 'translations.xml' -delete
fi
echo "Importing the strings..."
localazy download --config ./tools/localazy/localazy.json
echo "Removing the generated config"
rm ./tools/localazy/localazy.json
echo "Formatting the resources files..."
find . -name 'localazy.xml' -exec ./tools/localazy/formatXmlResourcesFile.py {} \;
if [[ $allFiles == 1 ]]; then
find . -name 'translations.xml' -exec ./tools/localazy/formatXmlResourcesFile.py {} \;
fi
echo "Checking forbidden terms..."
find . -name 'localazy.xml' -exec ./tools/localazy/checkForbiddenTerms.py {} \;
if [[ $allFiles == 1 ]]; then
find . -name 'translations.xml' -exec ./tools/localazy/checkForbiddenTerms.py {} \;
fi
echo "Success!"

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import re
import sys
from xml.dom import minidom
file = sys.argv[1]
content = minidom.parse(file)
# sort content by value of tag name
newContent = minidom.Document()
resources = newContent.createElement('resources')
resources.setAttribute('xmlns:xliff', 'urn:oasis:names:tc:xliff:document:1.2')
newContent.appendChild(resources)
resource = dict()
### Strings
for elem in content.getElementsByTagName('string'):
name = elem.attributes['name'].value
# Continue if value is empty
child = elem.firstChild
if child is None:
# Print an error to stderr
print('Warning: Empty content for string: ' + name + " in file " + file, file=sys.stderr)
continue
value = child.nodeValue
# Continue if string is empty
if value == '""':
# Print an error to stderr
print('Warning: Empty string value for string: ' + name + " in file " + file, file=sys.stderr)
continue
resource[name] = elem.cloneNode(True)
### Plurals
for elem in content.getElementsByTagName('plurals'):
plural = newContent.createElement('plurals')
name = elem.attributes['name'].value
plural.setAttribute('name', name)
for it in elem.childNodes:
if it.nodeType != it.ELEMENT_NODE:
continue
# Continue if value is empty
child = it.firstChild
if child is None:
# Print an error to stderr
print('Warning: Empty content for plurals: ' + name + " in file " + file, file=sys.stderr)
continue
value = child.nodeValue
# Continue if string is empty
if value == '""':
# Print an error to stderr
print('Warning: Empty item value for plurals: ' + name + " in file " + file, file=sys.stderr)
continue
plural.appendChild(it.cloneNode(True))
if plural.hasChildNodes():
resource[name] = plural
for key in sorted(resource.keys()):
resources.appendChild(resource[key])
result = newContent.toprettyxml(indent=" ") \
.replace('<?xml version="1.0" ?>', '<?xml version="1.0" encoding="utf-8"?>') \
.replace('&quot;', '"') \
.replace('...', '')
## Replace space by unbreakable space before punctuation
result = re.sub(r" ([\?\!\:…])", r" \1", result)
# Special treatment for French wording
if 'values-fr' in file:
## Replace ' with
result = re.sub(r"([cdjlmnsu])\\\'", r"\1", result, flags=re.IGNORECASE)
with open(file, "w") as text_file:
text_file.write(result)

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import json
import sys
# Read the config.json file
with open('./tools/localazy/config.json', 'r') as f:
config = json.load(f)
allFiles = sys.argv[1] == "1"
# Convert a module name to a path
# Ex: ":features:verifysession:impl" => "features/verifysession/impl"
def convertModuleToPath(name):
return name[1:].replace(":", "/")
# Regex that will be excluded from the Android project, you may add items here if necessary.
regexToAlwaysExclude = [
"Notification",
".*_ios"
]
baseAction = {
"type": "android",
# Replacement done in all string values
"replacements": {
"...": ""
},
"params": {
"force_underscore": "yes"
}
}
# Store all regex specific to module, to exclude the corresponding key from the common string module
allRegexToExcludeFromMainModule = []
# All actions that will be serialized in the localazy config
allActions = []
# Iterating on the config
for entry in config["modules"]:
# Create action for the default language
excludeRegex = regexToAlwaysExclude
if "excludeRegex" in entry:
excludeRegex += entry["excludeRegex"]
action = baseAction | {
"output": convertModuleToPath(entry["name"]) + "/src/main/res/values/localazy.xml",
"includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
"excludeKeys": list(map(lambda i: "REGEX:" + i, excludeRegex)),
"conditions": [
"equals: ${langAndroidResNoScript}, en | equals: ${file}, content.json"
]
}
# print(action)
allActions.append(action)
# Create action for the translations
if allFiles:
actionTranslation = baseAction | {
"output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml",
"includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
"excludeKeys": list(map(lambda i: "REGEX:" + i, excludeRegex)),
"conditions": [
"!equals: ${langAndroidResNoScript}, en | equals: ${file}, content.json"
],
"langAliases": {
"id": "in"
}
}
allActions.append(actionTranslation)
allRegexToExcludeFromMainModule.extend(entry["includeRegex"])
# Append configuration for the main string module: default language
mainAction = baseAction | {
"output": "libraries/ui-strings/src/main/res/values/localazy.xml",
"excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)),
"conditions": [
"equals: ${langAndroidResNoScript}, en | equals: ${file}, content.json"
]
}
# print(mainAction)
allActions.append(mainAction)
if allFiles:
# Append configuration for the main string module: translations
mainActionTranslation = baseAction | {
"output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml",
"excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)),
"conditions": [
"!equals: ${langAndroidResNoScript}, en | equals: ${file}, content.json"
],
"langAliases": {
"id": "in"
}
}
allActions.append(mainActionTranslation)
# Generate the configuration for localazy
result = {
"readKey": "a7876306080832595063-aa37154bb3772f6146890fca868d155b2228b492c56c91f67abdcdfb74d6142d",
"conversion": {
"actions": allActions
}
}
# Json serialization
with open('./tools/localazy/localazy.json', 'w') as json_file:
json.dump(result, json_file, indent=4, sort_keys=True)

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import os
import subprocess
def getLocalesFromLocalazy():
command = subprocess.run(
["localazy languages --read-key a7876306080832595063-aa37154bb3772f6146890fca868d155b2228b492c56c91f67abdcdfb74d6142d --csv"],
shell=True,
capture_output=True,
text=True,
)
data = command.stdout
result = []
for line in data.split("\n"):
if line:
line = line.split(",")
if (line[6] == "true"):
result.append(line[0])
return sorted(result)
def normalizeForResourceConfigurations(locale):
match locale:
case "id":
return "in"
case "zh_TW#Hant":
return "zh-rTW"
case "pt_BR":
return "pt-rBR"
case "zh#Hans":
return "zh-rCN"
case "en_US":
return "en-rUS"
case _:
return locale
def normalizeForLocalConfig(locale):
match locale:
case "id":
return "in"
case "zh_TW#Hant":
return "zh-TW"
case "zh#Hans":
return "zh-CN"
case _:
return locale
def generateLocaleFile(locales, file):
with open("plugins/src/main/kotlin/extension/locales.kt", "w") as f:
f.write("// File generated by " + file + ", do not edit\n\n")
f.write("package extension\n\n")
f.write("val locales = setOf(\n")
for locale in locales:
f.write(" \"" + normalizeForResourceConfigurations(locale) + "\",\n")
f.write(")\n")
def generateLocalesConfigFile(locales, file):
with open("app/src/main/res/xml/locales_config.xml", "w") as f:
f.write("<!-- File generated by " + file + ", do not edit -->\n")
f.write('<locale-config xmlns:android="http://schemas.android.com/apk/res/android">\n')
for locale in locales:
f.write(" <locale android:name=\"" + normalizeForLocalConfig(locale) + "\"/>\n")
f.write("</locale-config>\n")
def main():
file = os.path.basename(__file__)
locales = getLocalesFromLocalazy()
generateLocaleFile(locales, file)
generateLocalesConfigFile(locales, file)
if __name__ == "__main__":
main()

22
tools/quality/check.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# List of tasks to run before creating a PR, to limit the risk of getting rejected by the CI.
# Can be used as a git hook if you want.
# exit when any command fails
set -e
# First run the quickest script
./tools/check/check_code_quality.sh
# Check ktlint and Konsist first
./gradlew runQualityChecks
# Build, test and check the project, with warning as errors
./gradlew check -PallWarningsAsErrors=true

View File

@@ -0,0 +1,3 @@
See which PRs have been merged recently here:
https://github.com/element-hq/element-x-android/pulls?q=is%3Apr+sort%3Aupdated-desc+is%3Aclosed

View File

@@ -0,0 +1,227 @@
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <flx@obfusk.net>
# SPDX-License-Identifier: GPL-3.0-or-later.
import hashlib
import os
import re
import struct
import zipfile
import zlib
from binascii import hexlify
from typing import Any, Dict, Match, Tuple
DEX_MAGIC = b"dex\n"
DEX_MAGIC_RE = re.compile(rb"dex\n(\d{3})\x00")
PROF_MAGIC = b"pro\x00"
PROF_010_P = b"010\x00"
CLASSES_DEX_RE = re.compile(r"classes\d*\.dex")
ASSET_PROF = "assets/dexopt/baseline.prof"
PG_MAP_ID_RE = re.compile(rb'(~~R8{"backend":"dex".*?"pg-map-id":")([0-9a-f]{7})(")')
ATTRS = ("compress_type", "create_system", "create_version", "date_time",
"external_attr", "extract_version", "flag_bits")
LEVELS = (9, 6, 4, 1)
class Error(RuntimeError):
pass
# FIXME: is there a better alternative?
class ReproducibleZipInfo(zipfile.ZipInfo):
"""Reproducible ZipInfo hack."""
if "_compresslevel" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined]
if "compress_level" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined]
raise Error("zipfile.ZipInfo has no ._compresslevel")
_compresslevel: int
_override: Dict[str, Any] = {}
def __init__(self, zinfo: zipfile.ZipInfo, **override: Any) -> None:
# pylint: disable=W0231
if override:
self._override = {**self._override, **override}
for k in self.__slots__:
if hasattr(zinfo, k):
setattr(self, k, getattr(zinfo, k))
def __getattribute__(self, name: str) -> Any:
if name != "_override":
try:
return self._override[name]
except KeyError:
pass
return object.__getattribute__(self, name)
def fix_pg_map_id(input_dir: str, output_dir: str, map_id: str) -> None:
file_data = {}
for filename in [ASSET_PROF] + sorted(os.listdir(input_dir)):
if re.fullmatch(CLASSES_DEX_RE, filename) or filename == ASSET_PROF:
print(f"reading {filename!r}...")
with open(os.path.join(input_dir, *filename.split("/")), "rb") as fh:
file_data[filename] = fh.read()
_fix_pg_map_id(file_data, map_id)
for filename, data in file_data.items():
print(f"writing {filename!r}...")
if "/" in filename:
os.makedirs(os.path.join(output_dir, *filename.split("/")[:-1]), exist_ok=True)
with open(os.path.join(output_dir, *filename.split("/")), "wb") as fh:
fh.write(data)
def fix_pg_map_id_apk(input_apk: str, output_apk: str, map_id: str) -> None:
with open(input_apk, "rb") as fh_raw:
with zipfile.ZipFile(input_apk) as zf_in:
with zipfile.ZipFile(output_apk, "w") as zf_out:
file_data = {}
for info in zf_in.infolist():
if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF:
print(f"reading {info.filename!r}...")
file_data[info.filename] = zf_in.read(info)
_fix_pg_map_id(file_data, map_id)
for info in zf_in.infolist():
attrs = {attr: getattr(info, attr) for attr in ATTRS}
zinfo = ReproducibleZipInfo(info, **attrs)
if info.compress_type == 8:
fh_raw.seek(info.header_offset)
n, m = struct.unpack("<HH", fh_raw.read(30)[26:30])
fh_raw.seek(info.header_offset + 30 + m + n)
ccrc = 0
size = info.compress_size
while size > 0:
ccrc = zlib.crc32(fh_raw.read(min(size, 4096)), ccrc)
size -= 4096
with zf_in.open(info) as fh_in:
comps = {lvl: zlib.compressobj(lvl, 8, -15) for lvl in LEVELS}
ccrcs = {lvl: 0 for lvl in LEVELS}
while True:
data = fh_in.read(4096)
if not data:
break
for lvl in LEVELS:
ccrcs[lvl] = zlib.crc32(comps[lvl].compress(data), ccrcs[lvl])
for lvl in LEVELS:
if ccrc == zlib.crc32(comps[lvl].flush(), ccrcs[lvl]):
zinfo._compresslevel = lvl
break
else:
raise Error(f"Unable to determine compresslevel for {info.filename!r}")
elif info.compress_type != 0:
raise Error(f"Unsupported compress_type {info.compress_type}")
if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF:
print(f"writing {info.filename!r}...")
zf_out.writestr(zinfo, file_data[info.filename])
else:
with zf_in.open(info) as fh_in:
with zf_out.open(zinfo, "w") as fh_out:
while True:
data = fh_in.read(4096)
if not data:
break
fh_out.write(data)
def _fix_pg_map_id(file_data: Dict[str, bytes], map_id: str) -> None:
crcs = {}
for filename in file_data:
if re.fullmatch(CLASSES_DEX_RE, filename):
print(f"fixing {filename!r}...")
data = _fix_dex_id_checksum(file_data[filename], map_id.encode())
file_data[filename] = data
crcs[filename] = zlib.crc32(data)
if ASSET_PROF in file_data:
print(f"fixing {ASSET_PROF!r}...")
file_data[ASSET_PROF] = _fix_prof_checksum(file_data[ASSET_PROF], crcs)
def _fix_dex_id_checksum(data: bytes, map_id: bytes) -> bytes:
def repl(m: Match[bytes]) -> bytes:
print(f"fixing pg-map-id: {m.group(2)!r} -> {map_id!r}")
return m.group(1) + map_id + m.group(3)
magic = data[:8]
if magic[:4] != DEX_MAGIC or not DEX_MAGIC_RE.fullmatch(magic):
raise Error(f"Unsupported magic {magic!r}")
print(f"dex version={int(magic[4:7]):03d}")
checksum, signature = struct.unpack("<I20s", data[8:32])
fixed_data = re.sub(PG_MAP_ID_RE, repl, data[32:])
if fixed_data == data[32:]:
print("(not modified)")
return data
fixed_sig = hashlib.sha1(fixed_data).digest()
print(f"fixing signature: {hexlify(signature).decode()} -> {hexlify(fixed_sig).decode()}")
fixed_data = fixed_sig + fixed_data
fixed_checksum = zlib.adler32(fixed_data)
print(f"fixing checksum: 0x{checksum:x} -> 0x{fixed_checksum:x}")
return magic + int.to_bytes(fixed_checksum, 4, "little") + fixed_data
def _fix_prof_checksum(data: bytes, crcs: Dict[str, int]) -> bytes:
magic, data = _split(data, 4)
version, data = _split(data, 4)
if magic == PROF_MAGIC:
if version == PROF_010_P:
print("prof version=010 P")
return PROF_MAGIC + PROF_010_P + _fix_prof_010_p_checksum(data, crcs)
else:
raise Error(f"Unsupported prof version {version!r}")
else:
raise Error(f"Unsupported magic {magic!r}")
def _fix_prof_010_p_checksum(data: bytes, crcs: Dict[str, int]) -> bytes:
num_dex_files, uncompressed_data_size, compressed_data_size, data = _unpack("<BII", data)
dex_data_headers = []
if len(data) != compressed_data_size:
raise Error("Compressed data size does not match")
data = zlib.decompress(data)
if len(data) != uncompressed_data_size:
raise Error("Uncompressed data size does not match")
for i in range(num_dex_files):
profile_key_size, num_type_ids, hot_method_region_size, \
dex_checksum, num_method_ids, data = _unpack("<HHIII", data)
profile_key, data = _split(data, profile_key_size)
filename = profile_key.decode()
fixed_checksum = crcs[filename]
if fixed_checksum != dex_checksum:
print(f"fixing {filename!r} checksum: 0x{dex_checksum:x} -> 0x{fixed_checksum:x}")
dex_data_headers.append(struct.pack(
"<HHIII", profile_key_size, num_type_ids, hot_method_region_size,
fixed_checksum, num_method_ids) + profile_key)
fixed_data = b"".join(dex_data_headers) + data
fixed_cdata = zlib.compress(fixed_data, 1)
fixed_hdr = struct.pack("<BII", num_dex_files, uncompressed_data_size, len(fixed_cdata))
return fixed_hdr + fixed_cdata
def _unpack(fmt: str, data: bytes) -> Any:
assert all(c in "<BHI" for c in fmt)
size = fmt.count("B") + 2 * fmt.count("H") + 4 * fmt.count("I")
return struct.unpack(fmt, data[:size]) + (data[size:],)
def _split(data: bytes, size: int) -> Tuple[bytes, bytes]:
return data[:size], data[size:]
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(prog="fix-pg-map-id.py")
parser.add_argument("input_dir_or_apk", metavar="INPUT_DIR_OR_APK")
parser.add_argument("output_dir_or_apk", metavar="OUTPUT_DIR_OR_APK")
parser.add_argument("pg_map_id", metavar="PG_MAP_ID")
args = parser.parse_args()
if os.path.isdir(args.input_dir_or_apk):
fix_pg_map_id(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id)
else:
fix_pg_map_id_apk(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id)
# vim: set tw=80 sw=4 sts=4 et fdm=marker :

View File

@@ -0,0 +1,199 @@
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <flx@obfusk.net>
# SPDX-License-Identifier: GPL-3.0-or-later.
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
from typing import Optional, Tuple
COMMANDS = (
"fix-compresslevel",
"fix-files",
"fix-newlines",
"fix-pg-map-id",
"rm-files",
"sort-apk",
"sort-baseline",
)
BUILD_TOOLS_WITH_BROKEN_ZIPALIGN = ("31.0.0", "32.0.0")
BUILD_TOOLS_WITH_PAGE_SIZE_FROM = "35.0.0-rc1"
SDK_ENV = ("ANDROID_HOME", "ANDROID_SDK", "ANDROID_SDK_ROOT")
def _zipalign_cmd(page_align: bool, page_size: Optional[int]) -> Tuple[str, ...]:
if page_align:
if page_size is not None:
return ("zipalign", "-P", str(page_size), "4")
return ("zipalign", "-p", "4")
return ("zipalign", "4")
ZIPALIGN = _zipalign_cmd(page_align=False, page_size=None)
ZIPALIGN_P = _zipalign_cmd(page_align=True, page_size=None)
class Error(RuntimeError):
pass
def inplace_fix(command: str, input_file: str, *args: str,
zipalign: bool = False, page_align: bool = False,
page_size: Optional[int] = None, internal: bool = False) -> None:
if command not in COMMANDS:
raise Error(f"Unknown command {command}")
exe, script = _script_cmd(command)
ext = os.path.splitext(input_file)[1]
with tempfile.TemporaryDirectory() as tdir:
fixed = os.path.join(tdir, "fixed" + ext)
run_command(exe, script, input_file, fixed, *args, trim=2)
if zipalign:
aligned = os.path.join(tdir, "aligned" + ext)
zac = zipalign_cmd(page_align=page_align, page_size=page_size, internal=internal)
run_command(*zac, fixed, aligned, trim=2)
print(f"[MOVE] {aligned} to {input_file}")
shutil.move(aligned, input_file)
else:
print(f"[MOVE] {fixed} to {input_file}")
shutil.move(fixed, input_file)
def zipalign_cmd(page_align: bool = False, page_size: Optional[int] = None,
internal: bool = False) -> Tuple[str, ...]:
"""
Find zipalign command using $PATH or $ANDROID_HOME etc.
>>> zipalign_cmd()
('zipalign', '4')
>>> zipalign_cmd(page_align=True)
('zipalign', '-p', '4')
>>> zipalign_cmd(page_align=True, page_size=16)
('zipalign', '-P', '16', '4')
>>> cmd = zipalign_cmd(page_align=True, page_size=16, internal=True)
>>> [x.split("/")[-1] for x in cmd]
['python3', 'zipalign.py', '-P', '16', '4']
>>> os.environ["PATH"] = ""
>>> for k in SDK_ENV:
... os.environ[k] = ""
>>> cmd = zipalign_cmd()
>>> [x.split("/")[-1] for x in cmd]
['python3', 'zipalign.py', '4']
>>> os.environ["ANDROID_HOME"] = "test/fake-sdk"
>>> zipalign_cmd()
[SKIP BROKEN] 31.0.0
[FOUND] test/fake-sdk/build-tools/30.0.3/zipalign
('test/fake-sdk/build-tools/30.0.3/zipalign', '4')
>>> cmd = zipalign_cmd(page_align=True, page_size=16)
[SKIP TOO OLD] 31.0.0
[SKIP TOO OLD] 30.0.3
[SKIP TOO OLD] 26.0.2
>>> [x.split("/")[-1] for x in cmd]
['python3', 'zipalign.py', '-P', '16', '4']
>>> os.environ["ANDROID_HOME"] = "test/fake-sdk-2"
>>> zipalign_cmd(page_align=True, page_size=16)
[FOUND] test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign
('test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign', '-P', '16', '4')
"""
cmd, *args = _zipalign_cmd(page_align, page_size)
if not internal:
if shutil.which(cmd):
return (cmd, *args)
for k in SDK_ENV:
if home := os.environ.get(k):
tools = os.path.join(home, "build-tools")
if os.path.exists(tools):
for vsn in sorted(os.listdir(tools), key=_vsn, reverse=True):
if page_size and _vsn(vsn) < _vsn(BUILD_TOOLS_WITH_PAGE_SIZE_FROM):
print(f"[SKIP TOO OLD] {vsn}")
continue
for s in BUILD_TOOLS_WITH_BROKEN_ZIPALIGN:
if vsn.startswith(s):
print(f"[SKIP BROKEN] {vsn}")
break
else:
c = os.path.join(tools, vsn, cmd)
if shutil.which(c):
print(f"[FOUND] {c}")
return (c, *args)
return (*_script_cmd(cmd), *args)
def _vsn(v: str) -> Tuple[int, ...]:
"""
>>> vs = "31.0.0 32.1.0-rc1 34.0.0-rc3 34.0.0 35.0.0-rc1".split()
>>> for v in sorted(vs, key=_vsn, reverse=True):
... (_vsn(v), v)
((35, 0, 0, 0, 1), '35.0.0-rc1')
((34, 0, 0, 1, 0), '34.0.0')
((34, 0, 0, 0, 3), '34.0.0-rc3')
((32, 1, 0, 0, 1), '32.1.0-rc1')
((31, 0, 0, 1, 0), '31.0.0')
"""
if "-rc" in v:
v = v.replace("-rc", ".0.", 1)
else:
v = v + ".1.0"
return tuple(int(x) if x.isdigit() else -1 for x in v.split("."))
def _script_cmd(command: str) -> Tuple[str, str]:
script_dir = os.path.dirname(__file__)
for cmd in (command, command.replace("-", "_")):
script = os.path.join(script_dir, cmd + ".py")
if os.path.exists(script):
break
else:
raise Error(f"Script for {command} not found")
exe = sys.executable or "python3"
return exe, script
def run_command(*args: str, trim: int = 1) -> None:
targs = tuple(os.path.basename(a) for a in args[:trim]) + args[trim:]
print(f"[RUN] {' '.join(targs)}")
try:
subprocess.run(args, check=True)
except subprocess.CalledProcessError as e:
raise Error(f"{args[0]} command failed") from e
except FileNotFoundError as e:
raise Error(f"{args[0]} command not found") from e
def main() -> None:
prog = os.path.basename(sys.argv[0])
usage = (f"{prog} [-h] [--zipalign] [--page-align] [--page-size N] [--internal]\n"
f"{len('usage: ' + prog) * ' '} COMMAND INPUT_FILE [...]")
epilog = f"Commands: {', '.join(COMMANDS)}."
parser = argparse.ArgumentParser(usage=usage, epilog=epilog)
parser.add_argument("--zipalign", action="store_true",
help="run zipalign after COMMAND")
parser.add_argument("--page-align", action="store_true",
help="run zipalign w/ -p option (implies --zipalign)")
parser.add_argument("--page-size", metavar="N", type=int,
help="run zipalign w/ -P N option (implies --page-align)")
parser.add_argument("--internal", action="store_true",
help="use zipalign.py instead of searching $PATH/$ANDROID_HOME/etc.")
parser.add_argument("command", metavar="COMMAND")
parser.add_argument("input_file", metavar="INPUT_FILE")
args, rest = parser.parse_known_args()
try:
inplace_fix(args.command, args.input_file, *rest,
zipalign=bool(args.zipalign or args.page_align or args.page_size),
page_align=bool(args.page_align or args.page_size),
page_size=args.page_size, internal=args.internal)
except Error as e:
print(f"Error: {e}.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
# vim: set tw=80 sw=4 sts=4 et fdm=marker :

415
tools/release/release.sh Executable file
View File

@@ -0,0 +1,415 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# do not exit when any command fails (issue with git flow)
set +e
printf "\n================================================================================\n"
printf "| Welcome to the release script! |\n"
printf "================================================================================\n"
printf "Checking environment...\n"
envError=0
# Check that bundletool is installed
if ! command -v bundletool &> /dev/null
then
printf "Fatal: bundletool is not installed. You can install it running \`brew install bundletool\`\n"
envError=1
fi
# Path of the key store (it's a file)
keyStorePath="${ELEMENT_X_KEYSTORE_PATH}"
if [[ -z "${keyStorePath}" ]]; then
printf "Fatal: ELEMENT_X_KEYSTORE_PATH is not defined in the environment.\n"
envError=1
fi
# Keystore password
keyStorePassword="${ELEMENT_X_KEYSTORE_PASSWORD}"
if [[ -z "${keyStorePassword}" ]]; then
printf "Fatal: ELEMENT_X_KEYSTORE_PASSWORD is not defined in the environment.\n"
envError=1
fi
# Key password
keyPassword="${ELEMENT_X_KEY_PASSWORD}"
if [[ -z "${keyPassword}" ]]; then
printf "Fatal: ELEMENT_X_KEY_PASSWORD is not defined in the environment.\n"
envError=1
fi
# GitHub token
gitHubToken="${ELEMENT_GITHUB_TOKEN}"
if [[ -z "${gitHubToken}" ]]; then
printf "Fatal: ELEMENT_GITHUB_TOKEN is not defined in the environment.\n"
envError=1
fi
# Android home
androidHome="${ANDROID_HOME}"
if [[ -z "${androidHome}" ]]; then
printf "Fatal: ANDROID_HOME is not defined in the environment.\n"
envError=1
fi
# @elementbot:matrix.org matrix token / Not mandatory
elementBotToken="${ELEMENT_BOT_MATRIX_TOKEN}"
if [[ -z "${elementBotToken}" ]]; then
printf "Warning: ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment.\n"
fi
if [ ${envError} == 1 ]; then
exit 1
fi
# Read minSdkVersion from file plugins/src/main/kotlin/Versions.kt
minSdkVersion=$(grep "MIN_SDK_FOSS =" ./plugins/src/main/kotlin/Versions.kt |cut -d '=' -f 2 |xargs)
# Read buildToolsVersion from file plugins/src/main/kotlin/Versions.kt
buildToolsVersion=$(grep "BUILD_TOOLS_VERSION =" ./plugins/src/main/kotlin/Versions.kt |cut -d '=' -f 2 |xargs)
buildToolsPath="${androidHome}/build-tools/${buildToolsVersion}"
if [[ ! -d ${buildToolsPath} ]]; then
printf "Fatal: %s folder not found, ensure that you have installed the SDK version %s.\n" "${buildToolsPath}" "${buildToolsVersion}"
exit 1
fi
# Check if git flow is enabled
gitFlowDevelop=$(git config gitflow.branch.develop)
if [[ ${gitFlowDevelop} != "" ]]
then
printf "Git flow is initialized\n"
else
printf "Git flow is not initialized. Initializing...\n"
./tools/gitflow/gitflow-init.sh
fi
printf "OK\n"
printf "\n================================================================================\n"
printf "Ensuring main and develop branches are up to date...\n"
git checkout main
git pull
git checkout develop
git pull
printf "\n================================================================================\n"
# Guessing version to propose a default version
versionsFile="./plugins/src/main/kotlin/Versions.kt"
# Get current year on 2 digits
versionYearCandidate=$(date +%y)
currentVersionMonth=$(grep "val versionMonth" ${versionsFile} | cut -d " " -f6)
# Get current month on 2 digits
versionMonthCandidate=$(date +%m)
versionMonthCandidateNoLeadingZero=${versionMonthCandidate/#0/}
currentVersionReleaseNumber=$(grep "val versionReleaseNumber" ${versionsFile} | cut -d " " -f6)
# if the current month is the same as the current version, we increment the release number, else we reset it to 0
if [[ ${currentVersionMonth} -eq ${versionMonthCandidateNoLeadingZero} ]]; then
versionReleaseNumberCandidate=$((currentVersionReleaseNumber + 1))
else
versionReleaseNumberCandidate=0
fi
versionCandidate="${versionYearCandidate}.${versionMonthCandidate}.${versionReleaseNumberCandidate}"
read -r -p "Please enter the release version (example: ${versionCandidate}). Format must be 'YY.MM.x' or 'YY.MM.xy'. Just press enter if ${versionCandidate} is correct. " version
version=${version:-${versionCandidate}}
# extract year, month and release number for future use
versionYear=$(echo "${version}" | cut -d "." -f1)
versionMonth=$(echo "${version}" | cut -d "." -f2)
versionMonthNoLeadingZero=${versionMonth/#0/}
versionReleaseNumber=$(echo "${version}" | cut -d "." -f3)
printf "\n================================================================================\n"
printf "Starting the release %s\n" "${version}"
git flow release start "${version}"
# Note: in case the release is already started and the script is started again, checkout the release branch again.
ret=$?
if [[ $ret -ne 0 ]]; then
printf "Mmh, it seems that the release is already started. Checking out the release branch...\n"
git checkout "release/${version}"
fi
# Ensure version is OK
versionsFileBak="${versionsFile}.bak"
cp ${versionsFile} ${versionsFileBak}
sed "s/private const val versionYear = .*/private const val versionYear = ${versionYear}/" ${versionsFileBak} > ${versionsFile}
sed "s/private const val versionMonth = .*/private const val versionMonth = ${versionMonthNoLeadingZero}/" ${versionsFile} > ${versionsFileBak}
sed "s/private const val versionReleaseNumber = .*/private const val versionReleaseNumber = ${versionReleaseNumber}/" ${versionsFileBak} > ${versionsFile}
rm ${versionsFileBak}
git commit -a -m "Setting version for the release ${version}"
printf "\n================================================================================\n"
printf "Creating fastlane file...\n"
printf -v versionReleaseNumber2Digits "%02d" "${versionReleaseNumber}"
fastlaneFile="20${versionYear}${versionMonth}${versionReleaseNumber2Digits}0.txt"
fastlanePathFile="./fastlane/metadata/android/en-US/changelogs/${fastlaneFile}"
printf "Main changes in this version: bug fixes and improvements.\nFull changelog: https://github.com/element-hq/element-x-android/releases" > "${fastlanePathFile}"
read -r -p "I have created the file ${fastlanePathFile}, please edit it and press enter to continue. "
git add "${fastlanePathFile}"
git commit -a -m "Adding fastlane file for version ${version}"
printf "\n================================================================================\n"
printf "OK, finishing the release...\n"
git flow release finish "${version}"
printf "\n================================================================================\n"
read -r -p "Done, push the branch 'main' and the new tag (yes/no) default to yes? " doPush
doPush=${doPush:-yes}
if [ "${doPush}" == "yes" ]; then
printf "Pushing branch 'main' and tag 'v%s'...\n" "${version}"
git push origin main
git push origin "v${version}"
else
printf "Not pushing, do not forget to push manually!\n"
fi
printf "\n================================================================================\n"
printf "Checking out develop...\n"
git checkout develop
printf "\n================================================================================\n"
printf "The GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain should have start a new run.\n"
read -r -p "Please enter the url of the run, no need to wait for it to complete (example: https://github.com/element-hq/element-x-android/actions/runs/9065756777): " runUrl
targetPath="./tmp/Element/${version}"
printf "\n================================================================================\n"
printf "Downloading the artifacts...\n"
ret=1
while [[ $ret -ne 0 ]]; do
python3 ./tools/github/download_all_github_artifacts.py \
--token "${gitHubToken}" \
--runUrl "${runUrl}" \
--directory "${targetPath}"
ret=$?
if [[ $ret -ne 0 ]]; then
read -r -p "Error while downloading the artifacts. You may want to fix the issue and retry. Retry (yes/no) default to yes? " doRetry
doRetry=${doRetry:-yes}
if [ "${doRetry}" == "no" ]; then
exit 1
fi
fi
done
printf "\n================================================================================\n"
printf "Unzipping the F-Droid artifact...\n"
fdroidTargetPath="${targetPath}/fdroid"
unzip "${targetPath}"/elementx-app-fdroid-apks-unsigned.zip -d "${fdroidTargetPath}"
printf "\n================================================================================\n"
printf "Patching the FDroid APKs using inplace-fix.py...\n"
inplaceFixScript="./tools/release/inplace-fix.py"
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk '0000000'
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk '0000000'
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86-release.apk '0000000'
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk '0000000'
printf "\n================================================================================\n"
printf "Signing the FDroid APKs...\n"
cp "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk \
"${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk
cp "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk \
"${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk
cp "${fdroidTargetPath}"/app-fdroid-x86-release.apk \
"${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk
cp "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk \
"${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk
printf "\n================================================================================\n"
printf "Please check the information below:\n"
printf "File app-fdroid-arm64-v8a-release-signed.apk:\n"
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk | grep package
printf "File app-fdroid-armeabi-v7a-release-signed.apk:\n"
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk | grep package
printf "File app-fdroid-x86-release-signed.apk:\n"
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk | grep package
printf "File app-fdroid-x86_64-release-signed.apk:\n"
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk | grep package
printf "\n"
read -r -p "Does it look correct? Press enter when it's done. "
printf "\n================================================================================\n"
printf "The APKs in %s have been signed!\n" "${fdroidTargetPath}"
printf "\n================================================================================\n"
printf "Unzipping the Gplay artifact...\n"
gplayTargetPath="${targetPath}/gplay"
unzip "${targetPath}"/elementx-app-gplay-bundle-unsigned.zip -d "${gplayTargetPath}"
unsignedBundlePath="${gplayTargetPath}/app-gplay-release.aab"
signedBundlePath="${gplayTargetPath}/app-gplay-release-signed.aab"
printf "\n================================================================================\n"
printf "Signing file %s with build-tools version %s for min SDK version %s...\n" "${unsignedBundlePath}" "${buildToolsVersion}" "${minSdkVersion}"
cp "${unsignedBundlePath}" "${signedBundlePath}"
"${buildToolsPath}"/apksigner sign \
-v \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${signedBundlePath}"
printf "\n================================================================================\n"
printf "Please check the information below:\n"
printf "Version code: "
bundletool dump manifest --bundle="${signedBundlePath}" --xpath=/manifest/@android:versionCode
printf "Version name: "
bundletool dump manifest --bundle="${signedBundlePath}" --xpath=/manifest/@android:versionName
printf "\n"
read -r -p "Does it look correct? Press enter to continue. "
printf "\n================================================================================\n"
printf "The file %s has been signed and can be uploaded to the PlayStore!\n" "${signedBundlePath}"
printf "\n================================================================================\n"
read -r -p "Do you want to build the APKs from the app bundle? You need to do this step if you want to install the application to your device. (yes/no) default to no " doBuildApks
doBuildApks=${doBuildApks:-no}
if [ "${doBuildApks}" == "yes" ]; then
printf "Building apks...\n"
bundletool build-apks --bundle="${signedBundlePath}" --output="${gplayTargetPath}"/elementx.apks \
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
--overwrite
read -r -p "Do you want to install the application to your device? Make sure there is one (and only one!) connected device first. (yes/no) default to yes " doDeploy
doDeploy=${doDeploy:-yes}
if [ "${doDeploy}" == "yes" ]; then
printf "Installing apk for your device...\n"
bundletool install-apks --apks="${gplayTargetPath}"/elementx.apks
read -r -p "Please run the application on your phone to check that the upgrade went well. Press enter to continue. "
else
printf "APK will not be deployed!\n"
fi
else
printf "APKs will not be generated!\n"
fi
printf "\n================================================================================\n"
printf "Create the open testing release on GooglePlay.\n"
printf "On GooglePlay console, go the the open testing section and click on \"Create new release\" button, then:\n"
printf " - upload the file %s.\n" "${signedBundlePath}"
printf " - copy the release note from the fastlane file.\n"
printf " - download the universal APK, to be able to provide it to the GitHub release: click on the right arrow next to the \"App bundle\", then click on the \"Download\" tab, and download the \"Signed, universal APK\".\n"
printf " - submit the release.\n"
read -r -p "Press enter to continue. "
printf "You can then go to \"Publishing overview\" and send the new release for a review by Google.\n"
read -r -p "Press enter to continue. "
printf "\n================================================================================\n"
githubCreateReleaseLink="https://github.com/element-hq/element-x-android/releases/new?tag=v${version}&title=Element%20X%20Android%20v${version}"
printf "Creating the release on gitHub.\n"
printf -- "Open this link: %s\n" "${githubCreateReleaseLink}"
printf "Then\n"
printf " - Click on the 'Generate releases notes' button.\n"
printf " - Optionally reorder items and fix typos.\n"
printf " - Add the file %s to the GitHub release.\n" "${signedBundlePath}"
printf " - Add the universal APK, downloaded from the GooglePlay console to the GitHub release.\n"
printf " - Add the 4 signed APKs for F-Droid, located at %s to the GitHub release.\n" "${fdroidTargetPath}"
read -r -p ". Press enter to continue. "
printf "\n================================================================================\n"
printf "Update the project release notes:\n\n"
read -r -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. "
printf "\n================================================================================\n"
printf "Committing...\n"
git commit -a -m "Changelog for version ${version}"
printf "\n================================================================================\n"
read -r -p "Done, push the branch 'develop' (yes/no) default to yes? (A rebase may be necessary in case develop got new commits) " doPush
doPush=${doPush:-yes}
if [ "${doPush}" == "yes" ]; then
printf "Pushing branch 'develop'...\n"
git push origin develop
else
printf "Not pushing, do not forget to push manually!\n"
fi
printf "\n================================================================================\n"
printf "Message for the Android internal room:\n\n"
message="@room Element X Android ${version} is ready to be tested. You can get it from https://github.com/element-hq/element-x-android/releases/tag/v${version}. You can install the universal APK. If you want to install the application from the app bundle, you can follow instructions [here](https://github.com/element-hq/element-x-android/blob/develop/docs/install_from_github_release.md). Please report any feedback. Thanks!"
printf "%s\n\n" "${message}"
if [[ -z "${elementBotToken}" ]]; then
read -r -p "ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment. Cannot send the message for you. Please send it manually, and press enter to continue. "
else
read -r -p "Send this message to the room (yes/no) default to yes? " doSend
doSend=${doSend:-yes}
if [ "${doSend}" == "yes" ]; then
printf "Sending message...\n"
transactionId=$(openssl rand -hex 16)
# Element Android internal
matrixRoomId="!LiSLXinTDCsepePiYW:matrix.org"
curl -X PUT --data "{\"msgtype\":\"m.text\",\"body\":\"${message}\"}" -H "Authorization: Bearer ${elementBotToken}" https://matrix-client.matrix.org/_matrix/client/r0/rooms/${matrixRoomId}/send/m.room.message/\$local."${transactionId}"
else
printf "Message not sent, please send it manually!\n"
fi
fi
printf "\n================================================================================\n"
printf "Congratulation! Kudos for using this script! Have a nice day!\n"
printf "================================================================================\n"

82
tools/rte/build_rte.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Exit on error
set -e
# Ask to build from local source or to clone the repository
read -p "Do you want to build the RTE from local source (yes/no) default to yes? " buildLocal
buildLocal=${buildLocal:-yes}
date=$(gdate +%Y%m%d%H%M%S)
elementPwd=$(pwd)
# Ask for the RTE local source path
# if folder rte/ exists, use it as default
if [ "${buildLocal}" == "yes" ]; then
read -p "Please enter the path to the Rust SDK local source, default to ../matrix-rich-text-editor: " rtePath
rtePath=${rtePath:-../matrix-rich-text-editor/}
if [ ! -d "${rtePath}" ]; then
printf "\nFolder ${rtePath} does not exist. Please clone the matrix-rich-text-editor repository in the folder ../matrix-rich-text-editor.\n\n"
exit 0
fi
else
read -p "Please enter the RTE repository url, default to https://github.com/matrix-org/matrix-rich-text-editor.git " rteUrl
rteUrl=${rteUrl:-https://github.com/matrix-org/matrix-rich-text-editor.git}
read -p "Please enter the Rust SDK branch, default to main " rteBranch
rteBranch=${rteBranch:-main}
cd ..
git clone "${rteUrl}" matrix-rich-text-editor-"$date"
cd matrix-rich-text-editor-"$date"
git checkout "${rteBranch}"
rtePath=$(pwd)
cd "${elementPwd}"
fi
cd "${rtePath}"
git status
read -p "Will build with this version of the RTE ^. Is it correct (yes/no) default to yes? " rteCorrect
rteCorrect=${rteCorrect:-yes}
if [ "${rteCorrect}" != "yes" ]; then
exit 0
fi
# Ask if the user wants to build the app after
read -p "Do you want to build the app after (yes/no) default to yes? " buildApp
buildApp=${buildApp:-yes}
cd "${elementPwd}"
cd "$rtePath"
printf "\nBuilding the RTE for aarch64...\n\n"
make android-bindings-aarch64
cd platforms/android
./gradlew clean :library:assembleRelease :library-compose:assembleRelease
cp ./library/build/outputs/aar/library-release.aar "$elementPwd"/libraries/textcomposer/lib/library.aar
cp ./library-compose/build/outputs/aar/library-compose-release.aar "$elementPwd"/libraries/textcomposer/lib/library-compose.aar
cd "${elementPwd}"
mkdir -p ./libraries/textcomposer/lib/versions
cp ./libraries/textcomposer/lib/library.aar ./libraries/textcomposer/lib/versions/library-"${date}".aar
cp ./libraries/textcomposer/lib/library-compose.aar ./libraries/textcomposer/lib/versions/library-compose-"${date}".aar
if [ "${buildApp}" == "yes" ]; then
printf "\nBuilding the application...\n\n"
./gradlew assembleDebug
fi
if [ "${buildLocal}" == "no" ]; then
printf "\nCleaning up...\n\n"
rm -rf ../matrix-rich-text-editor-"$date"
fi
printf "\nDone!\n"

75
tools/sas/import_sas_emojis.py Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2020-2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import argparse
import json
import os
import os.path
# Run `pip3 install requests` if not installed yet
import requests
### Arguments
parser = argparse.ArgumentParser(description='Download sas string from matrix-doc.')
parser.add_argument('-v',
'--verbose',
help="increase output verbosity.",
action="store_true")
args = parser.parse_args()
if args.verbose:
print("Argument:")
print(args)
base_url = "https://raw.githubusercontent.com/matrix-org/matrix-spec/main/data-definitions/sas-emoji.json"
base_emoji_url = "https://raw.githubusercontent.com/twitter/twemoji/master/assets/svg/"
print("Downloading " + base_url + "")
r0 = requests.get(base_url)
data0 = json.loads(r0.content.decode())
if args.verbose:
print("Json data:")
print(data0)
print()
scripts_dir = os.path.dirname(os.path.abspath(__file__))
data_defs_dir = os.path.join(scripts_dir, "../../tmp/emoji/")
def handle_emoji(dict):
print("Handle emoji " + str(dict["number"]) + " (" + dict["description"] + ")…")
if args.verbose:
print("With")
print(dict)
# Transform dict["unicode"] from "U+2601U+FE0F" to "2601U"
emoji = dict["unicode"].split("U+")[1].lower()
url = base_emoji_url + emoji + ".svg"
file = os.path.join(data_defs_dir, "ic_verification_" + format(dict["number"], '02d') + ".svg")
print("Downloading " + url + " to " + file + "")
r = requests.get(url)
if r.status_code != 200:
print("Fatal: " + str(r.status_code))
# Stop script with error
sys.exit(1)
os.makedirs(os.path.dirname(file), exist_ok=True)
with open(file, "w") as f:
f.write(r.content.decode())
for emoji in data0:
handle_emoji(emoji)
print()
print("Success!")
print()
print("To convert to vector drawable, download tool from https://www.androiddesignpatterns.com/2018/11/android-studio-svg-to-vector-cli.html")
print("unzip it, and run:")
print("vd-tool/bin/vd-tool -c -in ./tmp/emoji -out features/verifysession/impl/src/main/res/drawable")

105
tools/sas/import_sas_strings.py Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2020-2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import argparse
import json
import os
import os.path
# Run `pip3 install requests` if not installed yet
import requests
### Arguments
parser = argparse.ArgumentParser(description='Download sas string from matrix-doc.')
parser.add_argument('-v',
'--verbose',
help="increase output verbosity.",
action="store_true")
args = parser.parse_args()
if args.verbose:
print("Argument:")
print(args)
base_url = "https://raw.githubusercontent.com/matrix-org/matrix-spec/main/data-definitions/sas-emoji.json"
print("Downloading " + base_url + "")
r0 = requests.get(base_url)
data0 = json.loads(r0.content.decode())
if args.verbose:
print("Json data:")
print(data0)
print()
# number -> translation
default = dict()
# Language -> emoji -> translation
cumul = dict()
for emoji in data0:
d = dict()
number = emoji["number"]
d["description"] = emoji["description"]
d["emoji"] = emoji["emoji"]
d["unicode"] = emoji["unicode"]
if args.verbose:
print("Dict: " + str(d))
default[number] = d
for lang in emoji["translated_descriptions"]:
if args.verbose:
print("Lang: " + lang)
if not (lang in cumul):
cumul[lang] = dict()
d = dict()
d["description"] = emoji["translated_descriptions"][lang]
cumul[lang][number] = d
if args.verbose:
print(default)
print(cumul)
def write_file(file, dict):
print("Writing file " + file)
if args.verbose:
print("With")
print(dict)
os.makedirs(os.path.dirname(file), exist_ok=True)
with open(file, mode="w", encoding="utf8") as o:
o.write("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n")
o.write("<resources>\n")
o.write(" <!-- Generated file, do not edit -->\n")
for key in dict:
if dict[key] is None:
continue
if dict[key]["description"] is None:
continue
o.write(" <string name=\"verification_emoji_" + format(key, '02d') + "\">" + dict[key]["description"].replace("'", "\\'") + "</string>\n")
o.write("</resources>\n")
scripts_dir = os.path.dirname(os.path.abspath(__file__))
data_defs_dir = os.path.join(scripts_dir, "../../features/verifysession/impl/src/main/res")
# Write default file
write_file(os.path.join(data_defs_dir, "values/strings_sas.xml"), default)
# Write each language file
for lang in cumul:
androidLang = lang \
.replace("_", "-r") \
.replace("zh-rHans", "zh-rCN") \
.replace("zh-rHant", "zh-rTW") \
.replace("id", "in")
write_file(os.path.join(data_defs_dir, "values-" + androidLang + "/strings_sas.xml"), cumul[lang])
print()
print("Success!")

101
tools/sdk/build_rust_sdk.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Exit on error
set -e
# Ask to build from local source or to clone the repository
read -p "Do you want to build the Rust SDK from local source (yes/no) default to yes? " buildLocal
buildLocal=${buildLocal:-yes}
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
date=$(date +%Y%m%d%H%M%S)
else
date=$(gdate +%Y%m%d%H%M%S)
fi
elementPwd=$(pwd)
# Ask for the Rust SDK local source path
# if folder rustSdk/ exists, use it as default
if [ "${buildLocal}" == "yes" ]; then
read -p "Please enter the path to the Rust SDK local source, default to ../matrix-rust-sdk" rustSdkPath
rustSdkPath=${rustSdkPath:-../matrix-rust-sdk/}
if [ ! -d "${rustSdkPath}" ]; then
printf "\nFolder ${rustSdkPath} does not exist. Please clone the matrix-rust-sdk repository in the folder ../matrix-rust-sdk.\n\n"
exit 0
fi
else
read -p "Please enter the Rust SDK repository url, default to https://github.com/matrix-org/matrix-rust-sdk.git " rustSdkUrl
rustSdkUrl=${rustSdkUrl:-https://github.com/matrix-org/matrix-rust-sdk.git}
read -p "Please enter the Rust SDK branch, default to main " rustSdkBranch
rustSdkBranch=${rustSdkBranch:-main}
cd ..
git clone "${rustSdkUrl}" matrix-rust-sdk-"$date"
cd matrix-rust-sdk-"$date"
git checkout "${rustSdkBranch}"
rustSdkPath=$(pwd)
cd "${elementPwd}"
fi
cd "${rustSdkPath}"
git status
read -p "Will build with this version of the Rust SDK ^. Is it correct (yes/no) default to yes? " sdkCorrect
sdkCorrect=${sdkCorrect:-yes}
if [ "${sdkCorrect}" != "yes" ]; then
exit 0
fi
# Ask if the user wants to build the app after
read -p "Do you want to build the app after (yes/no) default to no? " buildApp
buildApp=${buildApp:-no}
cd "${elementPwd}"
default_arch="$(uname -m)-linux-android"
# On ARM MacOS, `uname -m` returns arm64, but the toolchain is called aarch64
default_arch="${default_arch/arm64/aarch64}"
read -p "Enter the architecture you want to build for (default '$default_arch'): " target_arch
target_arch="${target_arch:-${default_arch}}"
# If folder ../matrix-rust-components-kotlin does not exist, clone the repo
if [ ! -d "../matrix-rust-components-kotlin" ]; then
printf "\nFolder ../matrix-rust-components-kotlin does not exist. Cloning the repository into ../matrix-rust-components-kotlin.\n\n"
git clone https://github.com/matrix-org/matrix-rust-components-kotlin.git ../matrix-rust-components-kotlin
fi
printf "\nResetting matrix-rust-components-kotlin to the latest main branch...\n\n"
cd ../matrix-rust-components-kotlin
git reset --hard
git checkout main
git pull
printf "\nBuilding the SDK for ${target_arch}...\n\n"
./scripts/build.sh -p "${rustSdkPath}" -m sdk -t "${target_arch}" -o "${elementPwd}/libraries/rustsdk"
cd "${elementPwd}"
mv ./libraries/rustsdk/sdk-android-debug.aar ./libraries/rustsdk/matrix-rust-sdk.aar
mkdir -p ./libraries/rustsdk/sdks
cp ./libraries/rustsdk/matrix-rust-sdk.aar ./libraries/rustsdk/sdks/matrix-rust-sdk-"${date}".aar
if [ "${buildApp}" == "yes" ]; then
printf "\nBuilding the application...\n\n"
./gradlew assembleDebug
fi
if [ "${buildLocal}" == "no" ]; then
printf "\nCleaning up...\n\n"
rm -rf ../matrix-rust-sdk-"$date"
fi
printf "\nDone!\n"

View File

@@ -0,0 +1 @@
{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":"","BUILD_GRADLE_API":"build.gradle.kts","BUILD_GRADLE_IMPL":"build.gradle.kts"},"fileTemplates":{"${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","${BUILD_GRADLE_API}":"Template Module Feature Build Gradle API","${BUILD_GRADLE_IMPL}":"Template Module Feature Build Gradle Impl","${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_API}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_IMPL}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false}

View File

@@ -0,0 +1,11 @@
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.${MODULE_NAME}.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View File

@@ -0,0 +1,25 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.${MODULE_NAME}.impl"
}
setupDependencyInjection()
dependencies {
api(projects.features.${MODULE_NAME}.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}

View File

@@ -0,0 +1,18 @@
package io.element.android.features.${MODULE_NAME}.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface ${FEATURE_NAME}EntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
// Add your callbacks
}
}

View File

@@ -0,0 +1,21 @@
package io.element.android.features.${MODULE_NAME}.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.${MODULE_NAME}.api.${FEATURE_NAME}EntryPoint
import io.element.android.libraries.architecture.createNode
import dev.zacsweers.metro.AppScope
@ContributesBinding(AppScope::class)
class Default${FEATURE_NAME}EntryPoint() : ${FEATURE_NAME}EntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: ${FEATURE_NAME}EntryPoint.Callback,
): Node {
return parentNode.createNode<${FEATURE_NAME}FlowNode>(buildContext, listOf(callback))
}
}

View File

@@ -0,0 +1,58 @@
package io.element.android.features.${MODULE_NAME}.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import dev.zacsweers.metro.AppScope
import kotlinx.parcelize.Parcelize
// CHANGE THE SCOPE
@ContributesNode(AppScope::class)
@AssistedInject
class ${FEATURE_NAME}FlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<${FEATURE_NAME}FlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
//Give your root node or completely delete this FlowNode if you have only one node.
createNode<>(buildContext)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View File

@@ -0,0 +1,23 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
import androidx.compose.runtime.Composable
import io.element.android.libraries.architecture.Presenter
import dev.zacsweers.metro.Inject
@Inject
class ${NAME}Presenter() : Presenter<${NAME}State> {
@Composable
override fun present(): ${NAME}State {
fun handleEvent(event: ${NAME}Event) {
when (event) {
${NAME}Event.MyEvent -> Unit
}
}
return ${NAME}State(
eventSink = ::handleEvent,
)
}
}

View File

@@ -0,0 +1,15 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class ${NAME}StateProvider : PreviewParameterProvider<${NAME}State> {
override val values: Sequence<${NAME}State>
get() = sequenceOf(
a${NAME}State(),
// Add other states here
)
}
fun a${NAME}State() = ${NAME}State(
eventSink = {}
)

View File

@@ -0,0 +1,30 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import dev.zacsweers.metro.AppScope
// CHANGE THE SCOPE
@ContributesNode(AppScope::class)
@AssistedInject
class ${NAME}Node(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ${NAME}Presenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
${NAME}View(
state = state,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,35 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun ${NAME}View(
state: ${NAME}State,
modifier: Modifier = Modifier,
) {
Box(modifier, contentAlignment = Alignment.Center) {
Text(
"${NAME} feature view",
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun ${NAME}ViewPreview(
@PreviewParameter(${NAME}StateProvider::class) state: ${NAME}State
) = ElementPreview {
${NAME}View(
state = state,
)
}

View File

@@ -0,0 +1,6 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
// TODO add your ui models. Remove the eventSink if you don't have events.
data class ${NAME}State(
val eventSink: (${NAME}Event) -> Unit
)

View File

@@ -0,0 +1,6 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
// TODO Add your events or remove the file completely if no events
sealed interface ${NAME}Event {
data object MyEvent: ${NAME}Event
}

View File

@@ -0,0 +1,18 @@
<application>
<component name="ExportableFileTemplateSettings">
<default_templates>
<template name="Template Presentation Classes.kt" file-name="${NAME}Presenter" reformat="true" live-template-enabled="false">
<template name="Template Presentation Classes.kt.child.0.kt" file-name="${NAME}StateProvider" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.1.kt" file-name="${NAME}Node" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.2.kt" file-name="${NAME}View" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.3.kt" file-name="${NAME}State" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.4.kt" file-name="${NAME}Event" reformat="true" live-template-enabled="false" />
</template>
<template name="Template Presentation Classes.kt.child.0.kt" file-name="${NAME}StateProvider" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.1.kt" file-name="${NAME}Node" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.2.kt" file-name="${NAME}View" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.3.kt" file-name="${NAME}State" reformat="true" live-template-enabled="false" />
<template name="Template Presentation Classes.kt.child.4.kt" file-name="${NAME}Event" reformat="true" live-template-enabled="false" />
</default_templates>
</component>
</application>

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2023-2024 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
echo "Zipping the contents of the 'files' directory..."
# Ensure tmp folder exists
mkdir -p tmp
rm -f ./tmp/file_templates.zip
pushd ./tools/templates/files || exit
zip -r ../../../tmp/file_templates.zip .
popd || exit

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import os
from util import compare
def checkInvalidScreenshots(reference):
__doc__ = "Detect invalid screenshot, by comparing to an invalid reference."
path_of_screenshots = "tests/uitests/src/test/snapshots/images/"
files = os.listdir(path_of_screenshots)
counter = 0
for file in files:
if not compare(reference, path_of_screenshots + file):
print("Invalid screenshot detected: " + file)
counter += 1
return counter
def main():
invalid_screenshot_reference_path = "tools/test/invalid_screenshot.png"
result = checkInvalidScreenshots(invalid_screenshot_reference_path)
if result > 0:
print("%d invalid screenshot(s) detected" % result)
print("Please check that the Preview is OK in Android Studio. You may want to use a Fake Composable for the screenshot to render correctly.")
exit(1)
else:
print("No invalid screenshot detected!")
exit(0)
main()

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
#
import os
import re
import sys
import time
from util import compare
# Read all arguments and return a list of them, this are the languages list.
def readArguments():
# Return sys.argv without the first argument
return sys.argv[1:]
def generateAllScreenshots(languages):
# If languages is empty, generate all screenshots
if len(languages) == 0:
print("Generating all screenshots...")
os.system("./gradlew recordPaparazziDebug -PallLanguages")
else:
tFile = "tests/uitests/src/test/kotlin/translations/TranslationsScreenshotTest.kt"
print("Generating screenshots for languages: %s" % languages)
# Record the languages one by one, else it's getting too slow
for lang in languages:
print("Generating screenshots for language: %s" % lang)
# Patch file TranslationsScreenshotTest.kt, replace `@TestParameter(value = ["de"])` with `@TestParameter(value = [<the languages>])`
with open(tFile, "r") as file:
data = file.read()
data = data.replace("@TestParameter(value = [\"de\"])", "@TestParameter(value = [\"%s\"])" % lang)
with open(tFile, "w") as file:
file.write(data)
os.system("./gradlew recordPaparazziDebug -PallLanguagesNoEnglish")
# Git reset the change on file TranslationsScreenshotTest.kt
os.system("git checkout HEAD -- %s" % tFile)
def detectLanguages():
__doc__ = "Detect languages from screenshots, other than English"
files = os.listdir("tests/uitests/src/test/snapshots/images/")
languages = set(map(lambda file: file[-6:-4], files))
languages = [lang for lang in languages if re.match("[a-z]", lang) and lang != "en"]
print("Detected languages: %s" % languages)
return languages
def deleteDuplicatedScreenshots(lang):
__doc__ = "Delete screenshots identical to the English version for a language"
print("Deleting screenshots identical to the English version for language %s..." % lang)
files = os.listdir("tests/uitests/src/test/snapshots/images/")
# Filter files by language
files = [file for file in files if file[-6:-4] == lang]
identicalFileCounter = 0
differentFileCounter = 0
for file in files:
englishFile = file[:-6] + "en" + file[-4:]
fullFile = "tests/uitests/src/test/snapshots/images/" + file
fullEnglishFile = "tests/uitests/src/test/snapshots/images/" + englishFile
isDifferent = compare(fullFile, fullEnglishFile)
if isDifferent:
differentFileCounter += 1
else:
identicalFileCounter += 1
os.remove(fullFile)
print("For language %s, keeping %d files and deleting %d files." % (lang, differentFileCounter, identicalFileCounter))
def moveScreenshots(lang):
__doc__ = "Move screenshots to the folder per language"
targetFolder = "screenshots/" + lang
print("Deleting existing screenshots for %s..." % lang)
os.system("rm -rf %s" % targetFolder)
print("Moving screenshots for %s to %s..." % (lang, targetFolder))
files = os.listdir("tests/uitests/src/test/snapshots/images/")
# Filter files by language
files = [file for file in files if file[-6:-4] == lang]
# Create the folder "./screenshots/<lang>"
os.makedirs(targetFolder, exist_ok=True)
for file in files:
fullFile = "tests/uitests/src/test/snapshots/images/" + file
os.rename(fullFile, targetFolder + "/" + file)
def detectRecordedLanguages():
# List all the subfolders of the screenshots folder which contains 2 letters, sorted alphabetically
return sorted([f for f in os.listdir("screenshots") if len(f) == 2])
def computeDarkFileName(lightFileName):
if "_Day" in lightFileName:
return lightFileName.replace("_Day", "_Night")
match = re.match("(.*)_Day_(\\d+)_(.*)", lightFileName, flags=re.ASCII)
if match:
return match.group(1) + "_Night_" + match.group(2) + "_" + match.group(3)
return ""
def checkForScreenshotNameDuplication():
__doc__ = "Check for screenshots name duplication"
print("Check for screenshots name duplication...")
files = os.listdir("tests/uitests/src/test/snapshots/images/")
dict = {}
for file in files:
start = file.find("_") + 1
end = file.find("_", start)
screenshotName = file[start:end]
if screenshotName in dict:
dict[screenshotName].append(file[:end])
else:
dict[screenshotName] = [file[:end]]
error = 0
for key in dict:
if key in ["Icon", "RoundIcon"]:
continue
values = set(dict[key])
if len(values) > 1:
print("Duplicated screenshot name: %s" % key)
for value in values:
print(" - %s" % value)
error += 1
if error:
print("Warning: %d duplicated screenshot name(s) found" % error)
def generateJavascriptFile():
__doc__ = "Generate a javascript file to load the screenshots"
print("Generating javascript file...")
languages = detectRecordedLanguages()
# First item is the list of languages, adding "en" and "en-dark" at the beginning
data = [["en", "en-dark"] + languages]
files = sorted(
os.listdir("tests/uitests/src/test/snapshots/images/"),
key=lambda file: file[file.find("_", 1):],
)
for file in files:
# Continue if file contains "_Night", keep only light screenshots
if "_Night" in file:
continue
dataForFile = [file[:-4]]
darkFile = computeDarkFileName(file)
if os.path.exists("./tests/uitests/src/test/snapshots/images/" + darkFile):
dataForFile.append(darkFile[:-4])
else:
dataForFile.append("")
for l in languages:
simpleFile = file[:-6] + l
translatedFile = "./screenshots/" + l + "/" + simpleFile + ".png"
if os.path.exists(translatedFile):
# Get the last modified date of the file in seconds and round to days
date = os.popen("git log -1 --format=%ct -- \"" + translatedFile + "\"").read().strip()
# if date is empty, use today's date
if date == "":
date = time.time()
dateDay = int(date) // 86400
dataForFile.append(dateDay)
else:
dataForFile.append(0)
data.append(dataForFile)
with open("screenshots/html/data.js", "w") as f:
f.write("// Generated file, do not edit\n")
f.write("export const screenshots = [\n")
for line in data:
f.write("[")
for item in line:
# If item is a string, add quotes
if isinstance(item, str):
f.write("\"" + item + "\",")
else:
f.write(str(item) + ",")
f.write("],\n")
f.write("];\n")
def main():
checkForScreenshotNameDuplication()
generateAllScreenshots(readArguments())
lang = detectLanguages()
for l in lang:
deleteDuplicatedScreenshots(l)
moveScreenshots(l)
generateJavascriptFile()
main()

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
#
import os
def detectAllExistingTranslations():
# Read all the folder in "libraries/ui-strings/src/main/res"
folders = os.listdir("libraries/ui-strings/src/main/res")
# Remove the "values" folder
folders.remove("values")
# Map to keep only the language code
folders = list(map(lambda folder: folder[7:], folders))
# Map to keep only the string before the "-"
folders = list(map(lambda folder: folder.split("-")[0], folders))
# Remove duplicates
folders = list(set(folders))
return folders
def main():
languages = detectAllExistingTranslations()
print ("Will record the screenshots for those languages: %s" % languages)
# Run the python script "generateAllScreenshots.py" with the detected languages
os.system("./tools/test/generateAllScreenshots.py %s" % " ".join(languages))
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

23
tools/test/util.py Normal file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2024, 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
import os
def compare(file1, file2):
__doc__ = "Compare two files, return True if different, False if identical."
# Compare file size
file1_stats = os.stat(file1)
file2_stats = os.stat(file2)
if file1_stats.st_size != file2_stats.st_size:
return True
# Compare file content
with open(file1, "rb") as f1, open(file2, "rb") as f2:
content1 = f1.read()
content2 = f2.read()
return content1 != content2