Compare commits
31 Commits
master
...
reproducib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2e8e490c6 | ||
|
|
38117ef477 | ||
|
|
3ec160983c | ||
|
|
fa8c181026 | ||
|
|
5a49b44f11 | ||
|
|
bc5f204f3d | ||
|
|
724f589791 | ||
|
|
25c02c82f2 | ||
|
|
d0c1284a3c | ||
|
|
4d74329ac9 | ||
|
|
8b48feba54 | ||
|
|
4f240a6ecb | ||
|
|
ae905607e2 | ||
|
|
e44b8e5f49 | ||
|
|
d40e3ce87a | ||
|
|
6824bc753a | ||
|
|
ea3c33189b | ||
|
|
a40238e383 | ||
|
|
5c3bfe5305 | ||
|
|
c67284a2d1 | ||
|
|
822fe9316d | ||
|
|
435cabbd41 | ||
|
|
6bcbd02e41 | ||
|
|
b0ba753ad1 | ||
|
|
403df62f2d | ||
|
|
7009cf30fe | ||
|
|
5d9b8c5ba7 | ||
|
|
61d168de17 | ||
|
|
8793b1e941 | ||
|
|
3b9ab70c82 | ||
|
|
0d8b4c6bd6 |
57
.dockerignore
Normal file
57
.dockerignore
Normal 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
|
||||
2
.github/workflows/build-release-apk.yml
vendored
2
.github/workflows/build-release-apk.yml
vendored
@ -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
1
.gitignore
vendored
@ -33,6 +33,7 @@ build/
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
reproducible-builds/build
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@ -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"
|
||||
@ -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")) {
|
||||
|
||||
64
reproducible-builds/Dockerfile
Normal file
64
reproducible-builds/Dockerfile
Normal 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
|
||||
3
reproducible-builds/apkdiff/.gitignore
vendored
Normal file
3
reproducible-builds/apkdiff/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
.venv
|
||||
mismatches
|
||||
1
reproducible-builds/apkdiff/.python-version
Normal file
1
reproducible-builds/apkdiff/.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.12
|
||||
408
reproducible-builds/apkdiff/apkdiff.py
Normal file
408
reproducible-builds/apkdiff/apkdiff.py
Normal 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)
|
||||
10
reproducible-builds/apkdiff/pyproject.toml
Normal file
10
reproducible-builds/apkdiff/pyproject.toml
Normal 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",
|
||||
]
|
||||
198
reproducible-builds/apkdiff/util.py
Normal file
198
reproducible-builds/apkdiff/util.py
Normal 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
1178
reproducible-builds/apkdiff/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
reproducible-builds/build-aab.sh
Executable file
44
reproducible-builds/build-aab.sh
Executable 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"
|
||||
32
reproducible-builds/build-apk.sh
Executable file
32
reproducible-builds/build-apk.sh
Executable 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"
|
||||
53
reproducible-builds/inside-docker.sh
Normal file
53
reproducible-builds/inside-docker.sh
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user