Compare commits

...

31 Commits

Author SHA1 Message Date
Ojok Emmanuel Nsubuga
d2e8e490c6
Merge branch 'master' into reproducibility 2026-06-14 14:11:09 +03:00
Ojok Emmanuel Nsubuga
38117ef477 OPS: Update detox version 2026-06-04 11:46:34 +03:00
Ojok Emmanuel Nsubuga
3ec160983c
Merge branch 'master' into reproducibility 2026-06-04 10:26:14 +03:00
Ojok Emmanuel Nsubuga
fa8c181026 OPS: clean up the zip files after usage 2026-06-04 10:25:23 +03:00
Ojok Emmanuel Nsubuga
5a49b44f11
Merge branch 'master' into reproducibility 2026-06-01 08:15:09 +03:00
Ivan Vershigora
bc5f204f3d
fix: add ios/build to dockerignore 2026-05-27 14:14:03 +01:00
Ojok Emmanuel Nsubuga
724f589791 OPS: Update apksigner 2026-05-27 06:25:02 +03:00
Ojok Emmanuel Nsubuga
25c02c82f2 OPS: Handle password securely 2026-05-25 22:03:46 +03:00
Ojok Emmanuel Nsubuga
d0c1284a3c
Merge branch 'master' into reproducibility 2026-05-25 18:27:11 +03:00
Ojok Emmanuel Nsubuga
4d74329ac9 OPS: Add debian snapshot and update build script for aab on Mac 2026-05-25 18:17:52 +03:00
Ojok Emmanuel Nsubuga
8b48feba54 OPS: Review feedback 2026-05-24 16:35:07 +03:00
Ojok Emmanuel Nsubuga
4f240a6ecb
Merge branch 'master' into reproducibility 2026-05-22 11:46:17 +03:00
Ojok Emmanuel Nsubuga
ae905607e2
Merge branch 'master' into reproducibility 2026-05-21 19:39:28 +03:00
Ojok Emmanuel Nsubuga
e44b8e5f49
Merge branch 'master' into reproducibility 2026-05-21 16:31:46 +03:00
Ojok Emmanuel Nsubuga
d40e3ce87a OPS: Restore file to match master version 2026-05-14 10:58:02 +03:00
Ojok Emmanuel Nsubuga
6824bc753a
Merge branch 'master' into reproducibility 2026-05-14 10:43:23 +03:00
Ojok Emmanuel Nsubuga
ea3c33189b OPS: Fix build failing on Macbook 2026-05-13 07:46:59 +03:00
Ojok Emmanuel Nsubuga
a40238e383 OPS: Update detox and ndk versions 2026-05-05 12:46:57 +03:00
Ojok Emmanuel Nsubuga
5c3bfe5305
Merge branch 'master' into reproducibility 2026-05-05 10:30:35 +03:00
Ojok Emmanuel Nsubuga
c67284a2d1 OPS: Update to build reproducible apk 2026-04-20 11:56:38 +03:00
Ojok Emmanuel Nsubuga
822fe9316d OPS: Update detox version 2026-04-09 20:14:12 +03:00
Ojok Emmanuel Nsubuga
435cabbd41
Merge branch 'master' into reproducibility 2026-04-09 19:36:48 +03:00
Ojok Emmanuel Nsubuga
6bcbd02e41 OPS: Improve docker image building performance
Add npm config for timeout and retry
Create android sdk dir and update permission at the top before adding files

Co-authored-by: winterrdog <winterrdog@protonmail.ch>
2026-04-09 16:01:28 +03:00
Ojok Emmanuel Nsubuga
b0ba753ad1 OPS: Apply Bugsnag plugin 2026-04-08 16:59:54 +03:00
Ojok Emmanuel Nsubuga
403df62f2d OPS: Add script to compare apks 2026-04-08 16:53:31 +03:00
Ojok Emmanuel Nsubuga
7009cf30fe OPS: Update Dockerfile and add build scripts 2026-04-08 16:47:24 +03:00
Ojok Emmanuel Nsubuga
5d9b8c5ba7 FIX: Update detox version 2026-03-28 07:22:08 +03:00
Ojok Emmanuel Nsubuga
61d168de17
Merge branch 'master' into reproducibility 2026-03-28 05:42:50 +03:00
Ojok Emmanuel Nsubuga
8793b1e941 OPS: Update detox version 2026-02-17 20:25:15 +03:00
Ojok Emmanuel Nsubuga
3b9ab70c82 OPS: add Dockerfile and .dockerignore for containerized builds 2026-02-17 13:07:58 +03:00
Ojok Emmanuel Nsubuga
0d8b4c6bd6 OPS: Update gradle configurations for reproducibility 2026-02-17 13:01:43 +03:00
15 changed files with 2075 additions and 7 deletions

57
.dockerignore Normal file
View File

@ -0,0 +1,57 @@
# Node modules
node_modules/
npm-debug.log
yarn-error.log
# Android build artifacts
android/.gradle/
android/build/
android/app/build/
android/.kotlin/
android/app/.cxx/
reproducible-builds/build
# Git
.git/
# IDE files
.idea/
*.iml
*.classpath
*.project
android/.settings/
android/app/.settings/
ios/BlueWallet.xcodeproj/xcuserdata/
.vscode/
.vs/
# Temporary / system files
.DS_Store
*.hprof
.metro-health-check*
artifacts/
# Fastlane outputs
fastlane/screenshots/
fastlane/test_output/
fastlane/report.xml
fastlane/Preview.html
fastlane/README.md
# BlueWallet specific
release-notes.json
release-notes.txt
current-branch.json
# iOS / Xcode
ios/build/
build/
DerivedData/
*.xcuserstate
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
.cxx/
*.ipa
*.hmap

View File

@ -64,7 +64,7 @@ jobs:
- name: Install Android SDK components
run: |
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;27.1.12297006"
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;28.2.13676358"
- name: Install node_modules (include dev deps for patch-package)
run: npm ci --yes

1
.gitignore vendored
View File

@ -33,6 +33,7 @@ build/
.gradle
local.properties
*.iml
reproducible-builds/build
# testing
/coverage

View File

@ -63,14 +63,14 @@ def enableProguardInReleaseBuilds = false
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.0.1`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.0.1'
android {
androidResources {
@ -114,6 +114,11 @@ android {
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
task copyFiatUnits(type: Copy) {
@ -131,7 +136,7 @@ tasks.configureEach { task ->
}
dependencies {
androidTestImplementation('com.wix:detox:+')
androidTestImplementation('com.wix:detox:20.51.3')
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation 'androidx.core:core-ktx:1.18.0'
@ -146,8 +151,9 @@ dependencies {
} else {
implementation jscFlavor
}
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
}
apply plugin: 'com.google.gms.google-services' // Google Services plugin
apply plugin: "com.bugsnag.android.gradle"
apply plugin: "com.bugsnag.android.gradle"

View File

@ -3,10 +3,11 @@
buildscript {
ext {
minSdkVersion = 24
buildToolsVersion = "36.0.0"
compileSdkVersion = 36
targetSdkVersion = 36
googlePlayServicesVersion = "16.+"
googlePlayServicesVersion = "16.1.0"
googlePlayServicesIidVersion = "16.0.1"
firebaseVersion = "21.1.0"
ndkVersion = "28.2.13676358"
@ -17,7 +18,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.android.tools.build:gradle:8.8.0")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
@ -135,8 +136,19 @@ subprojects { project ->
defaultConfig {
minSdkVersion 24
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
}
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
@ -145,6 +157,7 @@ subprojects { project ->
} else {
kotlinOptions.jvmTarget = sourceCompatibility
}
kotlinOptions.jvmTarget = "17"
}
if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) {

View File

@ -0,0 +1,64 @@
# Base image with Digest pinning
FROM node:24-bullseye-slim@sha256:e27057f6adaf0b3f172345fb5cdca821e07203ca81bc35f7b0a9e9631b255340
# Environment Setup
ENV TZ=UTC \
LANG=C \
LC_ALL=C \
SOURCE_DATE_EPOCH=315532800 \
ANDROID_SDK_ROOT=/opt/android-sdk \
JAVA_HOME=/usr/lib/jvm/java-17-openjdk
# Freeze Debian repositories to snapshot on May 24 2026
RUN printf '%s\n' \
'deb http://snapshot.debian.org/archive/debian/20260524T000000Z bullseye main contrib non-free' \
'deb http://snapshot.debian.org/archive/debian-security/20260524T000000Z bullseye-security main contrib non-free' \
> /etc/apt/sources.list \
&& echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99snapshot \
&& echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries
# Install system packages and create a stable JAVA_HOME symlink
RUN apt-get update && apt-get install -y --no-install-recommends \
curl=7.74.0-1.3+deb11u16 \
git=1:2.30.2-1+deb11u5 \
unzip=6.0-26+deb11u1 \
zip=3.0-12 \
libglu1-mesa=9.0.1-1 \
wget=1.21-1+deb11u2 \
xxd=2:8.2.2434-3+deb11u3 \
build-essential=12.9 \
openjdk-17-jdk-headless=17.0.19+10-1~deb11u1 \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sfn "$(dirname $(dirname $(readlink -f $(which java))))" /usr/lib/jvm/java-17-openjdk
# Update Path to include all tool locations
ENV PATH=$ANDROID_SDK_ROOT/cmdline-tools/11.0/bin:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/build-tools/36.0.0:$JAVA_HOME/bin:$PATH
# Create SDK directory with correct ownership
RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools \
&& chown -R node:node $ANDROID_SDK_ROOT
# Download, Verify, and Install Android Command Line Tools
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/tools.zip \
&& echo "2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258 /tmp/tools.zip" | sha256sum -c - \
&& unzip -X -q /tmp/tools.zip -d $ANDROID_SDK_ROOT/cmdline-tools \
&& mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/11.0 \
&& rm /tmp/tools.zip
USER node
# Install SDK Components (Aligned with build.gradle)
RUN yes | sdkmanager --sdk_root=$ANDROID_SDK_ROOT --licenses \
&& sdkmanager --sdk_root=$ANDROID_SDK_ROOT \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;28.2.13676358" \
"cmake;3.22.1" \
"platform-tools" \
&& rm -rf $ANDROID_SDK_ROOT/cmdline-tools/tmp
COPY --chown=node:node reproducible-builds/inside-docker.sh /app/inside-docker.sh
RUN chmod +x /app/inside-docker.sh
WORKDIR /app
COPY --chown=node:node . /app

View File

@ -0,0 +1,3 @@
__pycache__
.venv
mismatches

View File

@ -0,0 +1 @@
3.12

View File

@ -0,0 +1,408 @@
#! /usr/bin/env python3
# script laregly taken from
# https://github.com/signalapp/Signal-Android/tree/0010386b9e558e0f3d43d61180c818246226b1f9/reproducible-builds/apkdiff
import sys
import os
import re
import logging
from xml.etree.ElementTree import Element
from zipfile import ZipFile, BadZipFile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional
from collections import defaultdict
from androguard.core import axml
from loguru import logger
from util import deep_compare, format_differences
from tqdm import tqdm
logging.getLogger("deepdiff").setLevel(logging.ERROR)
logger.disable("androguard")
@dataclass
class XmlDifference:
"""Represents a difference between two XML elements."""
diff_type: str # "tag", "attribute", "text", "child_count"
path: str
attribute_name: Optional[str] = None
first_value: Optional[str] = None
second_value: Optional[str] = None
child_tag: Optional[str] = None
IGNORE_FILES = [
# Related to app signing. Not expected to be present in unsigned builds. Doesn"t affect app code.
"META-INF/MANIFEST.MF",
"META-INF/TEMP-KEY.SF",
"META-INF/TEMP-KEY.RSA",
"META-INF/MBLUEWAL.SF",
"META-INF/MBLUEWAL.RSA",
"META-INF/TEXTSECU.SF",
"META-INF/TEXTSECU.RSA",
"META-INF/BNDLTOOL.SF",
"META-INF/BNDLTOOL.RSA",
"META-INF/code_transparency_signed.jwt",
"stamp-cert-sha256",
]
ALLOWED_ARSC_DIFF_PATHS = [".res1"]
def open_apk(path: str) -> ZipFile:
if not os.path.exists(path):
print(f"ERROR: File not found: {path}")
sys.exit(2)
try:
return ZipFile(path, "r")
except BadZipFile:
print(f"ERROR: Invalid or corrupted APK (not a valid zip archive): {path}")
sys.exit(2)
def compare(apk1, apk2) -> bool:
print(f"Comparing: \n\t{apk1}\n\t{apk2}\n")
print("Unzipping...")
with open_apk(apk1) as zip1, open_apk(apk2) as zip2:
entry_names = compare_entry_names(zip1, zip2)
entry_contents = compare_entry_contents(zip1, zip2)
return entry_names and entry_contents
def compare_entry_names(zip1: ZipFile, zip2: ZipFile) -> bool:
print("Comparing zip entry names...")
name_list_sorted_1 = sorted(zip1.namelist())
name_list_sorted_2 = sorted(zip2.namelist())
for ignoreFile in IGNORE_FILES:
while ignoreFile in name_list_sorted_1:
name_list_sorted_1.remove(ignoreFile)
while ignoreFile in name_list_sorted_2:
name_list_sorted_2.remove(ignoreFile)
success = True
if len(name_list_sorted_1) != len(name_list_sorted_2):
print(f"Manifest lengths differ! {len(name_list_sorted_1)} vs {len(name_list_sorted_2)}")
success = False
only_in_first = sorted(list(set(name_list_sorted_1) - set(name_list_sorted_2)))
only_in_second = sorted(list(set(name_list_sorted_2) - set(name_list_sorted_1)))
if only_in_first:
print(f"Files present only in {zip1.filename}:")
for name in only_in_first:
print(f" - {name}")
success = False
if only_in_second:
print(f"Files present only in {zip2.filename}:")
for name in only_in_second:
print(f" - {name}")
success = False
# If sets are identical but ordering differs, still report ordering mismatches
if success:
for entry_name_1, entry_name_2 in zip(name_list_sorted_1, name_list_sorted_2):
if entry_name_1 != entry_name_2:
print(f"Sorted manifests don't match: {entry_name_1} vs {entry_name_2}")
success = False
return success
def compare_entry_contents(zip1: ZipFile, zip2: ZipFile) -> bool:
print("Comparing zip entry contents...")
info_list_1 = list(filter(lambda info: info.filename not in IGNORE_FILES, zip1.infolist()))
info_list_2 = list(filter(lambda info: info.filename not in IGNORE_FILES, zip2.infolist()))
success = True
if len(info_list_1) != len(info_list_2):
print(f"APK info lists of different length! {len(info_list_1)} vs {len(info_list_2)}")
success = False
for entry_info_1 in info_list_1:
for entry_info_2 in list(info_list_2):
if entry_info_1.filename == entry_info_2.filename:
entry_bytes_1 = zip1.read(entry_info_1.filename)
entry_bytes_2 = zip2.read(entry_info_2.filename)
if entry_bytes_1 != entry_bytes_2 and not handle_special_cases(entry_info_1.filename, entry_bytes_1, entry_bytes_2):
zip1.extract(entry_info_1, "mismatches/first")
zip2.extract(entry_info_2, "mismatches/second")
print(f"APKs differ on file {entry_info_1.filename}! Files extracted to the mismatches/ directory.")
success = False
info_list_2.remove(entry_info_2)
break
return success
def handle_special_cases(filename: str, bytes1: bytes, bytes2: bytes):
"""
There are some specific files that expect will not be byte-for-byte identical. We want to ensure that the files
are matching except these expected differences. The differences are all related to extra XML attributes that the
Play Store may add as part of the bundle process. These differences do not affect the behavior of the app and are
unfortunately unavoidable given the modern realities of the Play Store.
"""
if filename == "AndroidManifest.xml":
print("Comparing AndroidManifest.xml...")
return compare_android_xml(bytes1, bytes2)
elif filename == "resources.arsc":
print("Comparing resources.arsc (may take a while)...")
return compare_resources_arsc(bytes1, bytes2)
elif re.match("res/xml/splits[0-9]+\\.xml", filename):
print(f"Comparing {filename}...")
return compare_split_xml(bytes1, bytes2)
return False
def compare_android_xml(bytes1: bytes, bytes2: bytes) -> bool:
all_differences = compare_xml(bytes1, bytes2)
bad_differences = []
for diff in all_differences:
is_split_attr = diff.diff_type == "attribute" and diff.path in ["manifest", "manifest/application"] and diff.attribute_name is not None and "split" in diff.attribute_name.lower()
is_meta_attr = diff.diff_type == "attribute" and diff.path == "manifest/application/meta-data"
is_meta_child_count = diff.diff_type == "child_count" and diff.child_tag == "meta-data"
is_bugsnag_build_uuid = (
diff.diff_type == "attribute"
and diff.path == "manifest/application/meta-data"
and diff.attribute_name == "android:value"
and "BUILD_UUID" in (diff.first_value or diff.second_value or "")
)
if not is_split_attr and not is_meta_attr and not is_meta_child_count and not is_bugsnag_build_uuid:
bad_differences.append(diff)
if bad_differences:
print(bad_differences)
return False
return True
def compare_split_xml(bytes1: bytes, bytes2: bytes) -> bool:
all_differences = compare_xml(bytes1, bytes2)
bad_differences = []
for diff in all_differences:
is_language = diff.diff_type == "attribute" and diff.path == "splits/module/language/entry"
if not is_language:
bad_differences.append(diff)
if bad_differences:
print(bad_differences)
return False
return True
def compare_resources_arsc(first_entry_bytes: bytes, second_entry_bytes: bytes) -> bool:
"""
Compares two resources.arsc files.
Largely taken from https://github.com/TheTechZone/reproducible-tests/blob/d8c73772b87fbe337eb852e338238c95703d59d6/comparators/arsc_compare.py
"""
first_arsc = axml.ARSCParser(first_entry_bytes)
second_arsc = axml.ARSCParser(second_entry_bytes)
all_package_names = sorted(set(first_arsc.packages.keys()) | set(second_arsc.packages.keys()))
total_diffs = defaultdict(list)
success = True
for package_name in all_package_names:
# Check if package exists in both files
if package_name not in first_arsc.packages:
print(f"Package only in source file: {package_name}")
success = False
continue
if package_name not in second_arsc.packages:
print(f"Package only in target file: {package_name}")
success = False
continue
packages1 = first_arsc.packages[package_name]
packages2 = second_arsc.packages[package_name]
# Check package length
if len(packages1) != len(packages2):
print(f"Package length mismatch: {len(packages1)} vs {len(packages2)}")
success = False
continue
# Compare each package element
for i in tqdm(range(len(packages1))):
pkg1 = packages1[i]
pkg2 = packages2[i]
if type(pkg1) is not type(pkg2):
print(f"Element type mismatch at index {i}: {type(pkg1).__name__} vs {type(pkg2).__name__}")
success = False
continue
# Different comparison strategies based on type
if isinstance(pkg1, axml.ARSCResTablePackage):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in ARSCResTablePackage at index {i}:")
total_diffs["ARSCResTablePackage"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.StringBlock):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in StringBlock at index {i}:")
total_diffs["StringBlock"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.ARSCHeader):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in ARSCHeader at index {i}:")
total_diffs["ARSCHeader"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.ARSCResTypeSpec):
diffs = deep_compare(pkg1, pkg2)
if diffs and not all(path in ALLOWED_ARSC_DIFF_PATHS for path in diffs.keys()):
print(f"Disallowed differences in ARSCResTypeSpec at index {i}:")
print(format_differences(diffs))
total_diffs["ARSCResTypeSpec"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.ARSCResTableEntry):
# Use string representation for comparison
if pkg1.__repr__() != pkg2.__repr__():
print(f"Differences in ARSCResTableEntry at index {i}")
print(f"Target: {pkg1.__repr__()}", 3)
print(f"Source: {pkg2.__repr__()}", 3)
total_diffs["ARSCResTableEntry"].append((i, {"representation": f"{pkg1.__repr__()} vs {pkg2.__repr__()}"}))
success = False
elif isinstance(pkg1, list):
if pkg1 != pkg2:
print(f"List difference at index {i}")
total_diffs["list"].append((i, {"diff": "Lists differ"}))
success = False
elif isinstance(pkg1, axml.ARSCResType):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in ARSCResType at index {i}:")
total_diffs["ARSCResType"].append((i, diffs))
success = False
else:
# Other types
print(f"Unhandled type: {type(pkg1).__name__} at index {i}")
diffs = deep_compare(pkg1, pkg2)
if diffs:
total_diffs[type(pkg1).__name__].append((i, diffs))
success = False
for type_name, diffs in total_diffs.items():
if diffs:
print(f" {type_name}: {len(diffs)}", 1)
if not success:
print("Files have differences beyond the allowed .res1 differences.")
return success
def compare_xml(bytes1: bytes, bytes2: bytes) -> list[XmlDifference]:
printer = axml.AXMLPrinter(bytes1)
entry_text_1 = printer.get_xml().decode("utf-8")
printer = axml.AXMLPrinter(bytes2)
entry_text_2 = printer.get_xml().decode("utf-8")
if entry_text_1 == entry_text_2:
return []
root1 = ET.fromstring(entry_text_1)
root2 = ET.fromstring(entry_text_2)
return compare_xml_elements(root1, root2)
def compare_xml_elements(elem1: Element, elem2: Element, path: str = "") -> list[XmlDifference]:
"""Recursively compare two XML elements and return list of XmlDifference objects."""
differences: list[XmlDifference] = []
# Build current path
current_path = f"{path}/{elem1.tag}" if path else elem1.tag
# Compare tags
if elem1.tag != elem2.tag:
differences.append(XmlDifference(diff_type="tag", path=path, first_value=elem1.tag, second_value=elem2.tag))
return differences
# Compare attributes
attrs1 = elem1.attrib
attrs2 = elem2.attrib
all_keys = set(attrs1.keys()) | set(attrs2.keys())
for key in sorted(all_keys):
val1 = attrs1.get(key)
val2 = attrs2.get(key)
if val1 != val2:
differences.append(XmlDifference(diff_type="attribute", path=current_path, attribute_name=key, first_value=val1, second_value=val2))
# Compare text content
text1 = (elem1.text or "").strip()
text2 = (elem2.text or "").strip()
if text1 != text2:
differences.append(XmlDifference(diff_type="text", path=current_path, first_value=text1, second_value=text2))
# Compare children
children1 = list(elem1)
children2 = list(elem2)
# Try to match children by tag name for comparison
children1_by_tag: dict[str, list[Element]] = {}
for child in children1:
children1_by_tag.setdefault(child.tag, []).append(child)
children2_by_tag: dict[str, list[Element]] = {}
for child in children2:
children2_by_tag.setdefault(child.tag, []).append(child)
# Compare children with matching tags
all_child_tags = set(children1_by_tag.keys()) | set(children2_by_tag.keys())
for tag in sorted(all_child_tags):
list1 = children1_by_tag.get(tag, [])
list2 = children2_by_tag.get(tag, [])
if len(list1) != len(list2):
differences.append(XmlDifference(diff_type="child_count", path=current_path, child_tag=tag, first_value=str(len(list1)), second_value=str(len(list2))))
# Compare matching elements recursively
for child1, child2 in zip(list1, list2):
differences.extend(compare_xml_elements(child1, child2, current_path))
return differences
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: apkdiff <pathToFirstApk> <pathToSecondApk>")
sys.exit(1)
if compare(sys.argv[1], sys.argv[2]):
print("APKs match!")
sys.exit(0)
else:
print("APKs don't match!")
sys.exit(1)

View File

@ -0,0 +1,10 @@
[project]
name = "apkdiff"
version = "0.1.0"
description = "APK comparison utility"
requires-python = ">=3.12"
dependencies = [
"androguard>=4.1.3",
"tqdm>=4.67.1",
"loguru>=0.7.3",
]

View File

@ -0,0 +1,198 @@
# Script below taken from https://github.com/signalapp/Signal-Android/blob/main/reproducible-builds/apkdiff/util.py
# Utility functions taken from https://github.com/TheTechZone/reproducible-tests/blob/d8c73772b87fbe337eb852e338238c95703d59d6/comparators/arsc_compare.py
def format_differences(diffs, indent=0):
"""Format differences in a human-readable form"""
output = []
indent_str = " " * indent
for path, diff in sorted(diffs.items()):
if isinstance(diff, dict):
output.append(f"{indent_str}{path}:")
output.append(format_differences(diff, indent + 2))
elif isinstance(diff, list):
output.append(f"{indent_str}{path}: [{', '.join(map(str, diff))}]")
else:
output.append(f"{indent_str}{path}: {diff}")
return "\n".join(output)
def deep_compare(
obj1,
obj2,
path="",
max_depth=10,
current_depth=0,
exclude_attrs=None,
include_callable=False,
):
"""
Generic deep comparison of two Python objects.
Args:
obj1: First object to compare
obj2: Second object to compare
path: Current attribute path (for nested comparisons)
max_depth: Maximum recursion depth
current_depth: Current recursion depth
exclude_attrs: List of attribute names to exclude from comparison
include_callable: Whether to include callable attributes in comparison
Returns:
A dictionary mapping paths to differences, empty if objects are identical
"""
if exclude_attrs is None:
exclude_attrs = set()
else:
exclude_attrs = set(exclude_attrs)
# Add common attributes to exclude
exclude_attrs.update(["__dict__", "__weakref__", "__module__", "__doc__"])
differences = {}
# Check the recursion limit
if current_depth > max_depth:
return {f"{path} [max depth reached]": "Recursion limit reached"}
# Basic identity/equality check
if obj1 is obj2: # Same object (identity)
return {}
if obj1 == obj2: # Equal values
return {}
# Check for different types
if type(obj1) != type(obj2):
return {path: f"Type mismatch: {type(obj1).__name__} vs {type(obj2).__name__}"}
# Handle None
if obj1 is None or obj2 is None:
return {path: f"{obj1} vs {obj2}"}
# Handle primitive types
if isinstance(obj1, (int, float, str, bool, bytes, complex)):
return {path: f"{obj1} vs {obj2}"}
# Handle sequences (list, tuple)
if isinstance(obj1, (list, tuple)):
if len(obj1) != len(obj2):
differences[f"{path}.length"] = f"{len(obj1)} vs {len(obj2)}"
# Compare elements
for i in range(min(len(obj1), len(obj2))):
item_path = f"{path}[{i}]"
item_diffs = deep_compare(
obj1[i],
obj2[i],
item_path,
max_depth,
current_depth + 1,
exclude_attrs,
include_callable,
)
differences.update(item_diffs)
# Report extra elements
if len(obj1) > len(obj2):
for i in range(len(obj2), len(obj1)):
differences[f"{path}[{i}]"] = f"{obj1[i]} vs [missing]"
elif len(obj2) > len(obj1):
for i in range(len(obj1), len(obj2)):
differences[f"{path}[{i}]"] = f"[missing] vs {obj2[i]}"
return differences
# Handle dictionaries
if isinstance(obj1, dict):
keys1 = set(obj1.keys())
keys2 = set(obj2.keys())
# Check for different keys
if keys1 != keys2:
only_in_1 = keys1 - keys2
only_in_2 = keys2 - keys1
if only_in_1:
differences[f"{path}.keys_only_in_first"] = sorted(only_in_1)
if only_in_2:
differences[f"{path}.keys_only_in_second"] = sorted(only_in_2)
# Compare common keys
for key in keys1 & keys2:
key_path = f"{path}[{repr(key)}]"
key_diffs = deep_compare(
obj1[key],
obj2[key],
key_path,
max_depth,
current_depth + 1,
exclude_attrs,
include_callable,
)
differences.update(key_diffs)
return differences
# Handle sets
if isinstance(obj1, set):
only_in_1 = obj1 - obj2
only_in_2 = obj2 - obj1
if only_in_1:
differences[f"{path}.items_only_in_first"] = sorted(only_in_1)
if only_in_2:
differences[f"{path}.items_only_in_second"] = sorted(only_in_2)
return differences
# Handle custom objects and classes
try:
# Try to get all attributes
attrs1 = dir(obj1)
# Filter attributes
filtered_attrs = [attr for attr in attrs1 if not attr.startswith("__") and attr not in exclude_attrs and (include_callable or not callable(getattr(obj1, attr, None)))]
# Compare each attribute
for attr in filtered_attrs:
try:
# Skip unintended attributes
if attr in exclude_attrs:
continue
# Get attribute values
val1 = getattr(obj1, attr)
# Skip callables unless explicitly included
if callable(val1) and not include_callable:
continue
# Check if attr exists in obj2
if not hasattr(obj2, attr):
differences[f"{path}.{attr}"] = f"{val1} vs [attribute missing]"
continue
val2 = getattr(obj2, attr)
# Compare values
attr_path = f"{path}.{attr}"
attr_diffs = deep_compare(
val1,
val2,
attr_path,
max_depth,
current_depth + 1,
exclude_attrs,
include_callable,
)
differences.update(attr_diffs)
except Exception as e:
differences[f"{path}.{attr}"] = f"Error comparing: {str(e)}"
except Exception as e:
differences[path] = f"Error accessing attributes: {str(e)}"
return differences

1178
reproducible-builds/apkdiff/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="android-build-env"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$REPO_ROOT/reproducible-builds/build"
log() {
printf "\n[%s] %s\n" "$(date +'%H:%M:%S')" "$*" >&2
}
rm -rf "$OUT"
mkdir -p "$OUT"
chmod 775 "$OUT"
log "Building Docker image..."
docker build --platform linux/amd64 -f "$SCRIPT_DIR/Dockerfile" -t "$IMAGE_NAME" "$REPO_ROOT"
log "Running build inside container..."
docker run --platform linux/amd64 --rm \
-v "$OUT":/build \
"$IMAGE_NAME" \
bash -c "
set -e
umask 022
npm config set fetch-timeout 600000 \
&& npm config set fetch-retries 5 \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000 \
&& npm ci --verbose
cd android
./gradlew --no-daemon --no-build-cache bundleRelease
cp app/build/outputs/bundle/release/app-release.aab /build/Bluewallet-latest.aab
"
log "App bundle saved in $OUT"

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="android-build-env"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$REPO_ROOT/reproducible-builds/build"
log() {
printf "\n[%s] %s\n" "$(date +'%H:%M:%S')" "$*" >&2
}
rm -rf "$OUT"
mkdir -p "$OUT"
chmod 775 "$OUT"
log "Building Docker image..."
docker build --platform linux/amd64 -f "$SCRIPT_DIR/Dockerfile" -t "$IMAGE_NAME" "$REPO_ROOT"
log "Running build inside container..."
docker run --platform linux/amd64 --rm \
-e KEYSTORE_FILE_HEX \
-e KEYSTORE_PASSWORD \
-v "$OUT":/build \
"$IMAGE_NAME" \
bash /app/inside-docker.sh
log "Signed APK saved in $OUT"

View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
umask 022
npm config set fetch-timeout 600000
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm ci --verbose
cd android
./gradlew --no-daemon --no-build-cache assembleRelease
APK_UNSIGNED="app/build/outputs/apk/release/app-release-unsigned.apk"
APK_SIGNED="/tmp/app-release-signed.apk"
KEYSTORE="/tmp/keystore.jks"
if [ -n "${KEYSTORE_FILE_HEX:-}" ] && [ -n "${KEYSTORE_PASSWORD:-}" ]; then
printf "%s" "$KEYSTORE_FILE_HEX" | xxd -r -p > "$KEYSTORE"
apksigner sign \
--ks "$KEYSTORE" \
--ks-pass env:KEYSTORE_PASSWORD \
--key-pass env:KEYSTORE_PASSWORD \
--deterministic-dsa-signing \
--out "$APK_SIGNED" \
"$APK_UNSIGNED"
else
keytool -genkeypair \
-keystore "$KEYSTORE" \
-storepass password \
-keypass password \
-alias temp-key \
-keyalg RSA \
-keysize 2048 \
-validity 1 \
-dname "CN=Temporary,O=Build,C=US"
apksigner sign \
--ks "$KEYSTORE" \
--ks-key-alias temp-key \
--ks-pass pass:password \
--key-pass pass:password \
--deterministic-dsa-signing \
--out "$APK_SIGNED" \
"$APK_UNSIGNED"
fi
apksigner verify --verbose "$APK_SIGNED"
cp "$APK_SIGNED" /build/Bluewallet-latest.apk