Merge branch 'main' into feature/redesign

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt
#	settings.gradle.kts
#	ui-lib/build.gradle.kts
#	ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt
#	ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/view/HistoryView.kt
#	ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt
This commit is contained in:
Milan Cerovsky 2025-02-21 10:36:36 +01:00
commit 7d48e1590f
140 changed files with 1522 additions and 1856 deletions

View File

@ -11,9 +11,6 @@
# FIREBASE_DEBUG_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for debug builds
# FIREBASE_RELEASE_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for release builds
# Expected variables
# SUPPORT_EMAIL_ADDRESS - Contact email address for sending requests from the app
name: Deploy
on:
@ -142,7 +139,6 @@ jobs:
- name: Upload to Play Store
timeout-minutes: 25
env:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
# TODO [#1033]: Use token-based authorization on Google Play for automated deployment
# TODO [#1033]: https://github.com/Electric-Coin-Company/zashi-android/issues/1033
# Note that these properties are not currently used due to #1033

View File

@ -7,7 +7,6 @@
# Expected variables
# FIREBASE_TEST_LAB_PROJECT - Firebase Test Lab project name
# SUPPORT_EMAIL_ADDRESS - Contact email address for sending requests from the app
name: Pull Request
@ -33,6 +32,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
# Gradle Wrapper validation can be flaky
# https://github.com/gradle/wrapper-validation-action/issues/40
- name: Gradle Wrapper Validation
@ -83,6 +84,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -106,6 +109,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -146,6 +151,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -186,6 +193,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -201,7 +210,7 @@ jobs:
# Disable minify, since it makes lint run faster
ORG_GRADLE_PROJECT_IS_MINIFY_ENABLED: false
run: |
./gradlew :app:lintZcashmainnetRelease
./gradlew :app:lintZcashmainnetStoreRelease
- name: Collect Artifacts
if: ${{ always() }}
timeout-minutes: 1
@ -229,6 +238,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -273,6 +284,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -285,7 +298,7 @@ jobs:
- name: Build
timeout-minutes: 20
run: |
./gradlew assembleDebug assembleAndroidTest assembleZcashmainnetDebug assembleZcashtestnetDebug
./gradlew assembleZcashmainnetStoreDebug assembleZcashtestnetStoreDebug assembleAndroidTest
- name: Authenticate to Google Cloud for Firebase Test Lab
id: auth_test_lab
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935
@ -298,7 +311,6 @@ jobs:
- name: Test
timeout-minutes: 30
env:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
# Force blank suffix for screenshot tests
ORG_GRADLE_PROJECT_ZCASH_DEBUG_APP_NAME_SUFFIX: ""
# Used by Flank, since the temporary token is missing the project name
@ -338,6 +350,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -350,7 +364,6 @@ jobs:
- name: Build and test
timeout-minutes: 30
env:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
# Force blank suffix for screenshot tests
ORG_GRADLE_PROJECT_ZCASH_DEBUG_APP_NAME_SUFFIX: ""
ORG_GRADLE_PROJECT_ZCASH_EMULATOR_WTF_API_KEY: ${{ secrets.EMULATOR_WTF_API_KEY }}
@ -358,7 +371,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew testDebugWithEmulatorWtf :ui-integration-test:testZcashmainnetDebugWithEmulatorWtf
./gradlew testDebugWithEmulatorWtf :ui-integration-test:testZcashmainnetStoreDebugWithEmulatorWtf
- name: Collect Artifacts
if: ${{ always() }}
timeout-minutes: 1
@ -387,6 +400,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -399,7 +414,6 @@ jobs:
- name: Build and test
timeout-minutes: 30
env:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
# Force blank suffix for screenshot tests
ORG_GRADLE_PROJECT_ZCASH_DEBUG_APP_NAME_SUFFIX: ""
ORG_GRADLE_PROJECT_ZCASH_EMULATOR_WTF_API_KEY: ${{ secrets.EMULATOR_WTF_API_KEY }}
@ -407,7 +421,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:testZcashmainnetDebugWithEmulatorWtf :ui-screenshot-test:testZcashmainnetDebugWithEmulatorWtf
./gradlew :app:testZcashmainnetStoreDebugWithEmulatorWtf :ui-screenshot-test:testZcashmainnetStoreDebugWithEmulatorWtf
- name: Collect Artifacts
if: ${{ always() }}
timeout-minutes: 1
@ -439,6 +453,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -462,12 +478,11 @@ jobs:
- name: Build
timeout-minutes: 20
env:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
ORG_GRADLE_PROJECT_IS_CRASH_ON_STRICT_MODE_VIOLATION: true
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:assembleDebug
./gradlew :app:assembleZcashmainnetStoreDebug :app:assembleZcashtestnetStoreDebug
- name: Authenticate to Google Cloud for Firebase Test Lab
id: auth_test_lab
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935
@ -497,6 +512,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
@ -531,7 +548,6 @@ jobs:
- name: Build
timeout-minutes: 25
env:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PATH: ${{ format('{0}/release.jks', env.home) }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PASSWORD: android
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS: androiddebugkey
@ -539,7 +555,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:assembleDebug :app:bundleRelease :app:packageZcashmainnetReleaseUniversalApk
./gradlew :app:assembleZcashmainnetStoreDebug :app:assembleZcashtestnetStoreDebug :app:bundleZcashmainnetStoreRelease :app:bundleZcashtestnetStoreRelease :app:packageZcashmainnetStoreReleaseUniversalApk
- name: Collect Artifacts
timeout-minutes: 1
env:
@ -549,8 +565,8 @@ jobs:
COMPOSE_METRICS_ZIP_PATH: ${{ format('{0}/artifacts/compose_metrics.zip', env.home) }}
run: |
mkdir ${ARTIFACTS_DIR_PATH}
zip -r ${BINARIES_ZIP_PATH} . -i app/build/outputs/apk/\*/\*.apk app/build/outputs/apk_from_bundle/\*/\*.apk app/build/outputs/bundle/\*/\*.aab
zip -r ${MAPPINGS_ZIP_PATH} . -i *app/build/outputs/mapping/*/mapping.txt
zip -r ${BINARIES_ZIP_PATH} . -i app/build/outputs/apk/\*/\*/\*.apk app/build/outputs/apk_from_bundle/\*/\*.apk app/build/outputs/bundle/\*/\*.aab
zip -r ${MAPPINGS_ZIP_PATH} . -i *app/build/outputs/mapping/\*/\*/mapping.txt
zip -r ${COMPOSE_METRICS_ZIP_PATH} . -i \*/build/compose-metrics/\* \*/build/compose-reports/\*
- name: Upload Artifacts
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08
@ -572,6 +588,8 @@ jobs:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1

207
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,207 @@
name: Release
on:
release:
types:
- published # Runs only when a release is published
permissions:
contents: write # Grant write permissions to GITHUB_TOKEN
jobs:
validate_gradle_wrapper:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout
timeout-minutes: 1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# Gradle Wrapper validation can be flaky
# https://github.com/gradle/wrapper-validation-action/issues/40
- name: Gradle Wrapper Validation
timeout-minutes: 1
uses: gradle/wrapper-validation-action@f9c9c575b8b21b6485636a91ffecd10e558c62f6
check_secrets:
environment: deployment
permissions:
contents: read
runs-on: ubuntu-latest
outputs:
has-secrets: ${{ steps.check_secrets.outputs.defined }}
steps:
- id: check_secrets
env:
COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
if: "${{
env.COINBASE_APP_ID != ''
}}"
run: echo "defined=true" >> $GITHUB_OUTPUT
release:
environment: deployment
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # to fetch all commits
- name: Set up Google Cloud SDK
uses: google-github-actions/auth@v1
with:
credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }}
- name: Configure gsutil
run: gcloud auth activate-service-account --key-file <(echo '${{ secrets.GCP_SERVICE_ACCOUNT_KEY }}')
- name: Download file from GCS
run: gsutil -q cp gs://${{ secrets.GCP_PROJECT_ID_PROD }}-apt-packages/encrypted_gpg.kms encrypted_gpg.kms
- name: Decrypt file using KMS
run: |
gcloud kms decrypt \
--key gpg \
--keyring gpg \
--location global \
--plaintext-file private.pgp \
--ciphertext-file encrypted_gpg.kms
- name: Import GPG
run: |
gpg --import private.pgp
- name: Set up Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12
timeout-minutes: 1
with:
distribution: 'temurin'
java-version: 17
- name: Set up Gradle
uses: gradle/gradle-build-action@ac2d340dc04d9e1113182899e983b5400c17cda1
timeout-minutes: 10
with:
gradle-home-cache-cleanup: true
- name: Export Google Services JSON
env:
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
shell: bash
run: |
mkdir -p app/src/debug/
mkdir -p app/src/release/
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
- name: Set Env
shell: bash
run: |
echo "home=${HOME}" >> "$GITHUB_ENV"
- name: Export Signing Key
env:
# The upload key must be exported using `base64 -w 0 <filename.jks>` for use
# as a Github Secrets value; if the key is exported with standard wrapping,
# it will fail to import correctly.
# NOTE: This is the upload signing key, which may be replaced at will, not
# the application signing key which is escrowed by Google and may only be
# replaced once a year (and has a bunch of additional hassles associated with
# replacing it.)
SIGNING_KEYSTORE_BASE_64: ${{ secrets.UPLOAD_KEYSTORE_BASE_64 }}
SIGNING_KEY_PATH: ${{ format('{0}/release.jks', env.home) }}
shell: bash
run: |
echo ${SIGNING_KEYSTORE_BASE_64} | base64 --decode > ${SIGNING_KEY_PATH}
- name: Build Store APK
timeout-minutes: 25
env:
# TODO [#1033]: Use token-based authorization on Google Play for automated deployment
# TODO [#1033]: https://github.com/Electric-Coin-Company/zashi-android/issues/1033
# Note that these properties are not currently used due to #1033
# ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
# ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_KEY_FILE_PATH: ${{ steps.auth_google_play.outputs.credentials_file_path }}
ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_KEY }}
ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY: ${{ secrets.GOOGLE_PLAY_PUBLISHER_API_KEY }}
ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_DEPLOY_TRACK: internal
ORG_GRADLE_PROJECT_ZCASH_GOOGLE_PLAY_DEPLOY_STATUS: completed
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PATH: ${{ format('{0}/release.jks', env.home) }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEY_ALIAS_PASSWORD }}
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:bundleZcashmainnetStoreRelease :app:packageZcashmainnetStoreReleaseUniversalApk
- name: Prepare Store Artifacts
timeout-minutes: 1
run: |
mkdir artifacts/
mv app/build/outputs/apk_from_bundle/*/* artifacts/
- name: Strip non-Foss libraries
timeout-minutes: 10
run: |
rm app/src/debug/google-services.json
rm app/src/release/google-services.json
rm -rf build-conventions-secant/build/
rm -rf build-conventions-secant/.gradle/
rm -rf buildSrc/build/
rm -rf buildSrc/.gradle/
rm -rf ui-screenshot-test
rm -rf .gradle
rm -rf build-conventions-secant/src/main/kotlin/secant.emulator-wtf-conventions.gradle.kts
sed -i '/\/\/ start wtf maven/,/\/\/ end wtf maven/d' settings.gradle.kts
find . -type f -name "build.gradle.kts" -exec sed -i -e '/wtf.emulator.gradle/d' {} +
find . -type f -name "build.gradle.kts" -exec sed -i -e '/secant.emulator-wtf-conventions/d' {} +
find . -type f -name "gradle.lockfile" -exec sed -i -e '/wtf.emulator/d' {} +
find . -type f -name "buildscript-gradle.lockfile" -exec sed -i -e '/wtf.emulator/d' {} +
find . -type f -name "gradle.lockfile" -exec sed -i -e '/com.vdurmont/d' {} +
find . -type f -name "buildscript-gradle.lockfile" -exec sed -i -e '/com.vdurmont/d' {} +
find . -type f -name "gradle.lockfile" -exec sed -i -e '/org.json:json/d' {} +
find . -type f -name "buildscript-gradle.lockfile" -exec sed -i -e '/org.json:json/d' {} +
sed -i -e '/include("ui-screenshot-test")/d' settings.gradle.kts
sed -i -e '/com.google.gms/d' -e '/com.google.android.gms/d' -e '/com.google.firebase/d' -e '/crashlyticsVersion/d' build.gradle.kts
sed -i -e '/libs.google.services/d' -e '/libs.firebase/d' build.gradle.kts
sed -i -e '/com.google.gms/d' -e '/com.google.android.gms/d' -e '/com.google.firebase/d' buildscript-gradle.lockfile
sed -i -e '/libs.google.services/d' -e '/libs.firebase/d' buildscript-gradle.lockfile
sed -i -e '/com.google.gms.google-services/d' -e '/com.google.firebase.crashlytics/d' */build.gradle.kts
./gradlew clean
- name: Build Foss APK
timeout-minutes: 25
env:
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PATH: ${{ format('{0}/release.jks', env.home) }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEY_ALIAS_PASSWORD }}
# TODO [#1789] Re-enable Coinbase and Flexa integrations for FOSS variant
# ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
# ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:assembleZcashmainnetFossRelease
- name: Prepare Foss artifacts
timeout-minutes: 1
run: |
mv app/build/outputs/apk/zcashmainnetFoss/release/app-zcashmainnet-foss-release.apk artifacts/
- name: Prepare Signature artifacts
timeout-minutes: 1
run: |
cd artifacts/
TAG=$(git describe --tags --abbrev=0)
VERSION_NAME=$(echo "$TAG" | cut -d'-' -f1)
VERSION_CODE=$(echo "$TAG" | cut -d'-' -f2)
echo $VERSION_NAME > version_code.txt
echo $VERSION_CODE > version_code.txt
gpg -u sysadmin@z.cash --armor --digest-algo SHA256 --detach-sign *foss*.apk
gpg -u sysadmin@z.cash --armor --digest-algo SHA256 --detach-sign *store*.apk
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -7,10 +7,14 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
## [Unreleased]
### Added
- The new `Foss` build dimension has been added that suits for Zashi build that follows FOSS principles
- The `release.yaml` has been added. It provides us with ability to build and deploy Zashi to GitHub Releases and
F-Droid store.
- Confirm the rejection of a Keystone transaction dialog added.
### Changed
- `Flexa` version has been bumped to 1.0.11
- Several non-FOSS dependencies has been removed for the new FOSS Zashi build type
- Keystone flows swapped the buttons for the better UX, the main CTA is the closes button for a thumb.
## [1.3.3 (839)] - 2025-01-23

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021-2023 Zcash
Copyright (c) 2021-2025 Zcash
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -48,8 +48,8 @@ the project, these steps are not necessary.)
1. See `ZCASH_RELEASE_APP_NAME`
1. Change the package name under [app/build.gradle.kts](app/build.gradle.kts)
1. See `ZCASH_RELEASE_PACKAGE_NAME`
1. Change the support email address under [gradle.properties](gradle.properties)
1. See `ZCASH_SUPPORT_EMAIL_ADDRESS`
1. Change the support email address under [strings.xml](ui-lib/src/main/res/ui/non_translatable/values/strings.xml)
1. See `support_email_address`
1. Remove any copyrighted ZCash or Electric Coin Company icons, logos, or assets
1. ui-lib/src/main/res/common/ - All of the the ic_launcher assets
1. Optional

View File

@ -1,6 +1,9 @@
import co.electriccoin.zcash.Git
import com.android.build.api.variant.BuildConfigField
import com.android.build.api.variant.ResValue
import model.BuildType
import model.DistributionDimension
import model.NetworkDimension
import java.util.Locale
plugins {
@ -65,21 +68,34 @@ android {
buildConfig = true
}
flavorDimensions.add("network")
flavorDimensions += listOf(NetworkDimension.DIMENSION_NAME, DistributionDimension.DIMENSION_NAME)
val testNetFlavorName = "zcashtestnet"
productFlavors {
// would rather name them "testnet" and "mainnet" but product flavor names cannot start with the word "test"
create(testNetFlavorName) {
dimension = "network"
applicationId = "$packageName.testnet" // allow to be installed alongside mainnet
matchingFallbacks.addAll(listOf("zcashtestnet", "debug"))
create(NetworkDimension.TESTNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
applicationId = packageName
applicationIdSuffix = ".testnet"
matchingFallbacks.addAll(listOf(NetworkDimension.TESTNET.value, BuildType.DEBUG.value))
}
create("zcashmainnet") {
dimension = "network"
create(NetworkDimension.MAINNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
applicationId = packageName
matchingFallbacks.addAll(listOf("zcashmainnet", "release"))
matchingFallbacks.addAll(listOf(NetworkDimension.MAINNET.value, BuildType.RELEASE.value))
}
create(DistributionDimension.STORE.value) {
dimension = DistributionDimension.DIMENSION_NAME
applicationId = packageName
matchingFallbacks.addAll(listOf(DistributionDimension.STORE.value, BuildType.RELEASE.value))
}
create(DistributionDimension.FOSS.value) {
dimension = DistributionDimension.DIMENSION_NAME
applicationId = packageName
matchingFallbacks.addAll(listOf(DistributionDimension.FOSS.value, BuildType.RELEASE.value))
versionNameSuffix = "-foss"
applicationIdSuffix = ".foss"
}
}
@ -98,7 +114,7 @@ android {
signingConfigs {
if (isReleaseSigningConfigured) {
// If this block doesn't execute, the output will be unsigned
create("release").apply {
create(BuildType.RELEASE.value).apply {
storeFile = File(releaseKeystorePath)
storePassword = releaseKeystorePassword
keyAlias = releaseKeyAlias
@ -108,16 +124,16 @@ android {
}
buildTypes {
getByName("debug").apply {
getByName(BuildType.DEBUG.value).apply {
// Note that the build-conventions defines the res configs
isPseudoLocalesEnabled = true
// Suffixing app package name and version to avoid collisions with other installed Zcash
// Suffixing app package name and version to avoid collisions with other installed Zashi
// apps (e.g. from Google Play)
versionNameSuffix = "-debug"
applicationIdSuffix = ".debug"
}
getByName("release").apply {
getByName(BuildType.RELEASE.value).apply {
isMinifyEnabled = project.property("IS_MINIFY_ENABLED").toString().toBoolean()
isShrinkResources = project.property("IS_MINIFY_ENABLED").toString().toBoolean()
ndk.debugSymbolLevel = project.property("NDK_DEBUG_SYMBOL_LEVEL").toString()
@ -134,10 +150,10 @@ android {
val isSignReleaseBuildWithDebugKey = project.property("IS_SIGN_RELEASE_BUILD_WITH_DEBUG_KEY")
.toString().toBoolean()
if (isReleaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName(BuildType.RELEASE.value)
} else if (isSignReleaseBuildWithDebugKey) {
// Warning: in this case is the release build signed with the debug key
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName(BuildType.DEBUG.value)
}
}
}
@ -146,22 +162,44 @@ android {
applicationVariants.all {
val defaultAppName = project.property("ZCASH_RELEASE_APP_NAME").toString()
val debugAppNameSuffix = project.property("ZCASH_DEBUG_APP_NAME_SUFFIX").toString()
val supportEmailAddress = project.property("ZCASH_SUPPORT_EMAIL_ADDRESS").toString()
val fossAppNameSuffix = project.property("ZCASH_FOSS_APP_NAME_SUFFIX").toString()
when (this.name) {
"zcashtestnetDebug" -> {
resValue("string", "app_name", "$defaultAppName ($testnetNetworkName)$debugAppNameSuffix")
"zcashtestnetStoreDebug" -> {
resValue("string", "app_name", "$defaultAppName $debugAppNameSuffix $testnetNetworkName")
}
"zcashmainnetDebug" -> {
resValue("string", "app_name", "$defaultAppName$debugAppNameSuffix")
"zcashmainnetStoreDebug" -> {
resValue("string", "app_name", "$defaultAppName $debugAppNameSuffix")
}
"zcashtestnetRelease" -> {
resValue("string", "app_name", "$defaultAppName ($testnetNetworkName)")
"zcashtestnetStoreRelease" -> {
resValue("string", "app_name", "$defaultAppName $testnetNetworkName")
}
"zcashmainnetRelease" -> {
"zcashmainnetStoreRelease" -> {
resValue("string", "app_name", defaultAppName)
}
"zcashtestnetFossDebug" -> {
resValue(
"string",
"app_name",
"$defaultAppName $fossAppNameSuffix $debugAppNameSuffix $testnetNetworkName"
)
}
"zcashmainnetFossDebug" -> {
resValue("string", "app_name", "$defaultAppName $fossAppNameSuffix $debugAppNameSuffix")
}
"zcashtestnetFossRelease" -> {
resValue("string", "app_name", "$defaultAppName $fossAppNameSuffix $testnetNetworkName")
}
"zcashmainnetFossRelease" -> {
resValue("string", "app_name", defaultAppName)
}
}
resValue("string", "support_email_address", supportEmailAddress)
}
dependenciesInfo {
// Disables dependency metadata when building APKs
includeInApk = false
// Disables dependency metadata when building Android App Bundles
includeInBundle = false
}
testCoverage {
@ -222,30 +260,13 @@ androidComponents {
ResValue(value = hasFirebaseApiKeys.toString())
)
if (project.property("ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY").toString().isNotEmpty() &&
project.property("ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY").toString().isNotEmpty()
if ((project.property("ZCASH_GOOGLE_PLAY_SERVICE_ACCOUNT_KEY").toString().isNotEmpty() &&
project.property("ZCASH_GOOGLE_PLAY_PUBLISHER_API_KEY").toString().isNotEmpty()) ||
variant.productFlavors.any { it.second == DistributionDimension.FOSS.value }
) {
// Update the versionName to reflect bumps in versionCode
val versionCodeOffset = 0 // Change this to zero the final digit of the versionName
val processedVersionCode = output.versionCode.map { playVersionCode ->
val defaultVersionName = project.property("ZCASH_VERSION_NAME").toString()
// Version names will look like `myCustomVersionName.123`
@Suppress("UNNECESSARY_SAFE_CALL")
playVersionCode?.let {
val delta = it - versionCodeOffset
if (delta < 0) {
defaultVersionName
} else {
"$defaultVersionName ($delta)"
}
} ?: defaultVersionName
}
output.versionName.set(processedVersionCode)
val gitInfo = Git.newInfo(Git.MAIN, parent!!.projectDir)
val defaultVersionName = project.property("ZCASH_VERSION_NAME").toString()
output.versionName.set(defaultVersionName)
val gitInfo = Git.newInfo(Git.MAIN, rootDir)
output.versionCode.set(gitInfo.commitCount)
}
}
@ -255,7 +276,7 @@ androidComponents {
))
// The fixed Locale.US is intended
if (variant.name.lowercase(Locale.US).contains("release")) {
if (variant.name.lowercase(Locale.US).contains(BuildType.RELEASE.value)) {
variant.packaging.resources.excludes.addAll(listOf(
"**/*.kotlin_metadata",
"DebugProbesKt.bin",
@ -318,7 +339,7 @@ fladle {
debugApk.set(
project.provider {
"${buildDirectory}/outputs/apk/zcashmainnet/debug/app-zcashmainnet-debug.apk"
"${buildDirectory}/outputs/apk/zcashmainnetStore/debug/app-zcashmainnet-store-debug.apk"
}
)
@ -337,7 +358,7 @@ fladle {
debugApk.set(
project.provider {
"$buildDirectory" +
"/outputs/apk_from_bundle/zcashmainnetRelease/app-zcashmainnet-release-universal.apk"
"/outputs/apk_from_bundle/zcashmainnetStoreRelease/app-zcashmainnet-store-release-universal.apk"
}
)

View File

@ -3,6 +3,8 @@ package co.electriccoin.zcash.app
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.crash.android.di.CrashReportersProvider
import co.electriccoin.zcash.crash.android.di.crashProviderModule
import co.electriccoin.zcash.di.addressBookModule
import co.electriccoin.zcash.di.coreModule
import co.electriccoin.zcash.di.dataSourceModule
@ -28,6 +30,7 @@ class ZcashApplication : CoroutineApplication() {
private val standardPreferenceProvider by inject<StandardPreferenceProvider>()
private val flexaRepository by inject<FlexaRepository>()
private val applicationStateProvider: ApplicationStateProvider by inject()
private val getAvailableCrashReporters: CrashReportersProvider by inject()
override fun onCreate() {
super.onCreate()
@ -42,6 +45,7 @@ class ZcashApplication : CoroutineApplication() {
modules(
coreModule,
providerModule,
crashProviderModule,
dataSourceModule,
repositoryModule,
addressBookModule,
@ -83,7 +87,7 @@ class ZcashApplication : CoroutineApplication() {
}
private fun configureAnalytics() {
if (GlobalCrashReporter.register(this)) {
if (GlobalCrashReporter.register(this, getAvailableCrashReporters())) {
applicationScope.launch {
StandardPreferenceKeys.IS_ANALYTICS_ENABLED.observe(standardPreferenceProvider()).collect {
if (it) {

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name="co.electriccoin.zcash.app.ZcashApplication"
android:icon="@mipmap/ic_launcher_square"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name">
<provider
android:name="co.electriccoin.zcash.global.ShareFileProvider"
android:authorities="co.electriccoin.zcash.foss.debug.provider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities" >
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/share_file_provider_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name="co.electriccoin.zcash.app.ZcashApplication"
android:icon="@mipmap/ic_launcher_square"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name">
<provider
android:name="co.electriccoin.zcash.global.ShareFileProvider"
android:authorities="co.electriccoin.zcash.foss.provider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities" >
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/share_file_provider_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name="co.electriccoin.zcash.app.ZcashApplication"
android:icon="@mipmap/ic_launcher_square"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name">
<provider
android:name="co.electriccoin.zcash.global.ShareFileProvider"
android:authorities="co.electriccoin.zcash.foss.provider.testnet"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities" >
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/share_file_provider_paths" />
</provider>
</application>
</manifest>

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,7 @@
package model
enum class BuildType(val value: String) {
DEBUG("debug"),
RELEASE("release"),
BENCHMARK("benchmark")
}

View File

@ -0,0 +1,19 @@
package model
enum class NetworkDimension(val value: String) {
MAINNET("zcashmainnet"),
TESTNET("zcashtestnet");
companion object {
const val DIMENSION_NAME = "network"
}
}
enum class DistributionDimension(val value: String) {
STORE("store"),
FOSS("foss");
companion object {
const val DIMENSION_NAME = "distribution"
}
}

View File

@ -182,7 +182,7 @@ abstract class PublishToGooglePlay @Inject constructor(
val packageName = project.property("ZCASH_RELEASE_PACKAGE_NAME").toString()
// Walk through the build directory and find the prepared release aab file
val apkFile = File("app/build/outputs/bundle/").walk()
val apkFile = File("app/build/outputs/bundle/zcashmainnetStoreRelease/").walk()
.filter { it.name.endsWith("release.aab") }
.firstOrNull() ?: error("Universal release apk not found")
@ -408,11 +408,6 @@ tasks {
PublishStatus.new(it)
}
// The new task [publishToGooglePlay] runs [assembleDebug] and [bundleZcashmainnetRelease] as its
// dependencies.
// Note that we need to convert these Enums to Strings as enums are not assignable via Kotlin DSL to Gradle
// custom task, although it would be better to work with more type-safe Enums furthermore.
register<PublishToGooglePlay>(
"publishToGooglePlay", // $NON-NLS-1$
googlePlayServiceAccountKey,
@ -420,7 +415,7 @@ tasks {
deployTrack.toGooglePlayIdentifier(),
deployStatus.toGooglePlayIdentifier()
)
.dependsOn(":app:assembleDebug")
.dependsOn(":app:bundleZcashmainnetRelease")
.dependsOn(":app:packageZcashmainnetReleaseUniversalApk")
.dependsOn(":app:assembleZcashmainnetStoreDebug")
.dependsOn(":app:bundleZcashmainnetStoreRelease")
.dependsOn(":app:packageZcashmainnetStoreReleaseUniversalApk")
}

View File

@ -1,5 +1,6 @@
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ManagedVirtualDevice
import model.BuildType
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
pluginManager.withPlugin("com.android.application") {
@ -97,7 +98,7 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension() {
}
buildTypes {
getByName("debug").apply {
getByName(BuildType.DEBUG.value).apply {
val coverageEnabled =
project.property("IS_ANDROID_INSTRUMENTATION_TEST_COVERAGE_ENABLED").toString().toBoolean()
isTestCoverageEnabled = coverageEnabled
@ -111,7 +112,7 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension() {
val isExplicitDebugSigningEnabled = !debugKeystorePath.isNullOrBlank()
if (isExplicitDebugSigningEnabled) {
// If this block doesn't execute, the output will still be signed with the default keystore
getByName("debug").apply {
getByName(BuildType.DEBUG.value).apply {
storeFile = File(debugKeystorePath)
}
}

View File

@ -16,8 +16,9 @@ private val gitCommitCountKey = "gitCommitCount"
private val releaseNotesEn = "releaseNotesEn"
private val releaseNotesEs = "releaseNotesEs"
private val releaseNotesEnPath = "docs/whatsNew/WHATS_NEW_EN.md"
private val releaseNotesEsPath = "docs/whatsNew/WHATS_NEW_ES.md"
private val releaseNotesEnPath = "${project.rootDir}/docs/whatsNew/WHATS_NEW_EN.md"
private val releaseNotesEsPath = "${project.rootDir}/docs/whatsNew/WHATS_NEW_ES.md"
// Injects build information
// Note timestamp is not currently injected because it effectively disables the cache since it
@ -25,9 +26,9 @@ private val releaseNotesEsPath = "docs/whatsNew/WHATS_NEW_ES.md"
val generateBuildConfigTask = tasks.create("buildConfig") {
val generatedDir = layout.buildDirectory.dir("generated").get().asFile
val gitInfo = co.electriccoin.zcash.Git.newInfo(
val gitInfo = Git.newInfo(
Git.HEAD,
parent!!.projectDir
rootDir
)
inputs.property(gitShaKey, gitInfo.sha)

View File

@ -62,9 +62,6 @@ plugins {
id("secant.rosetta-conventions")
}
val uiIntegrationModuleName: String = projects.uiIntegrationTest.name
val uiScreenshotModuleName: String = projects.uiScreenshotTest.name
tasks {
withType<com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask> {
gradleReleaseChannel = "current"
@ -101,7 +98,6 @@ tasks {
"ZCASH_RELEASE_APP_NAME" to "Zashi",
"ZCASH_RELEASE_PACKAGE_NAME" to "co.electriccoin.zcash",
"ZCASH_SUPPORT_EMAIL_ADDRESS" to "support@electriccoin.co",
"IS_SECURE_SCREEN_PROTECTION_ACTIVE" to "true",
"IS_SCREEN_ROTATION_ENABLED" to "false",

View File

@ -16,7 +16,7 @@ object Git {
val git = Git.open(workingDirectory)
val repository = git.repository
val head: ObjectId = repository.resolve(branch)
val head = repository.resolve(branch)
val count = git.log().call().count()
return GitInfo(ObjectId.toString(head), count)

View File

@ -1,3 +1,6 @@
import model.DistributionDimension
import model.NetworkDimension
plugins {
id("com.android.library")
kotlin("android")
@ -21,17 +24,39 @@ android {
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
flavorDimensions += listOf(NetworkDimension.DIMENSION_NAME, DistributionDimension.DIMENSION_NAME)
productFlavors {
create(NetworkDimension.TESTNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
}
create(NetworkDimension.MAINNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
}
create(DistributionDimension.STORE.value) {
dimension = DistributionDimension.DIMENSION_NAME
}
create(DistributionDimension.FOSS.value) {
dimension = DistributionDimension.DIMENSION_NAME
}
}
}
dependencies {
api(libs.androidx.annotation)
api(projects.crashLib)
implementation(platform(libs.firebase.bom))
api(libs.bundles.koin)
"storeImplementation"(platform(libs.firebase.bom))
"storeImplementation"(libs.firebase.crashlytics)
"storeImplementation"(libs.firebase.crashlytics.ndk)
"storeImplementation"(libs.firebase.installations)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.installations)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(projects.spackleAndroidLib)

View File

@ -0,0 +1,12 @@
package co.electriccoin.zcash.crash.android.internal
import android.content.Context
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
class ListCrashReportersImpl : ListCrashReporters {
override fun provideReporters(context: Context): List<CrashReporter> {
return listOfNotNull(
LocalCrashReporter.getInstance(context),
)
}
}

View File

@ -1,32 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- We want better control over the timing of Firebase initialization -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name=".internal.local.CrashProcessNameContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
<receiver
android:name=".internal.local.ExceptionReceiver"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
</application>
</manifest>

View File

@ -4,8 +4,7 @@ import android.content.Context
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.crash.android.internal.firebase.FirebaseCrashReporter
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
import co.electriccoin.zcash.crash.android.internal.ListCrashReporters
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.spackle.process.ProcessNameCompat
import java.util.Collections
@ -24,7 +23,10 @@ object GlobalCrashReporter {
* @return True if registration occurred and false if registration was skipped.
*/
@MainThread
fun register(context: Context): Boolean {
fun register(
context: Context,
reporters: ListCrashReporters
): Boolean {
if (isCrashProcess(context)) {
Twig.debug { "Skipping registration for $CRASH_PROCESS_NAME_SUFFIX process" } // $NON-NLS
return false
@ -34,15 +36,7 @@ object GlobalCrashReporter {
if (registeredCrashReporters == null) {
registeredCrashReporters =
Collections.synchronizedList(
// To prevent a race condition, register the LocalCrashReporter first.
// FirebaseCrashReporter does some asynchronous registration internally, while
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// and write the default UncaughtExceptionHandler. The only way to ensure
// interleaving doesn't happen is to register the LocalCrashReporter first.
listOfNotNull(
LocalCrashReporter.getInstance(context),
FirebaseCrashReporter(context),
)
reporters.provideReporters(context)
)
}
}

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.crash.android.di
import co.electriccoin.zcash.crash.android.internal.ListCrashReportersImpl
class CrashReportersProvider {
operator fun invoke() = ListCrashReportersImpl()
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.crash.android.di
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val crashProviderModule =
module {
factoryOf(::CrashReportersProvider)
}

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.crash.android.internal
import android.content.Context
interface ListCrashReporters {
fun provideReporters(context: Context): List<CrashReporter>
}

View File

@ -1,7 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
<!-- Expected to be overridden by a resource overlay in the app module, generated
based on the presence of the Firebase API keys -->
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
</resources>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- We want better control over the timing of Firebase initialization -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name=".internal.local.CrashProcessNameContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
<receiver
android:name=".internal.local.ExceptionReceiver"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
</application>
</manifest>

View File

@ -0,0 +1,17 @@
package co.electriccoin.zcash.crash.android.internal
import android.content.Context
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
class ListCrashReportersImpl : ListCrashReporters {
override fun provideReporters(context: Context): List<CrashReporter> {
// To prevent a race condition, register the LocalCrashReporter first.
// FirebaseCrashReporter does some asynchronous registration internally, while
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// and write the default UncaughtExceptionHandler. The only way to ensure
// interleaving doesn't happen is to register the LocalCrashReporter first.
return listOfNotNull(
LocalCrashReporter.getInstance(context),
)
}
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
<!-- Expected to be overridden by a resource overlay in the app module, generated
based on the presence of the Firebase API keys -->
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
</resources>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- We want better control over the timing of Firebase initialization -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name=".internal.local.CrashProcessNameContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
<receiver
android:name=".internal.local.ExceptionReceiver"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
</application>
</manifest>

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.crash.android.internal
import android.content.Context
import co.electriccoin.zcash.crash.android.internal.firebase.FirebaseCrashReporter
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
class ListCrashReportersImpl : ListCrashReporters {
override fun provideReporters(context: Context): List<CrashReporter> {
// To prevent a race condition, register the LocalCrashReporter first.
// FirebaseCrashReporter does some asynchronous registration internally, while
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// and write the default UncaughtExceptionHandler. The only way to ensure
// interleaving doesn't happen is to register the LocalCrashReporter first.
return listOfNotNull(
LocalCrashReporter.getInstance(context),
FirebaseCrashReporter(context),
)
}
}

View File

@ -0,0 +1,39 @@
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import com.google.firebase.FirebaseApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
object FirebaseAppCache {
private val mutex = Mutex()
@Volatile
private var cachedFirebaseApp: FirebaseAppContainer? = null
fun peekFirebaseApp(): FirebaseApp? = cachedFirebaseApp?.firebaseApp
suspend fun getFirebaseApp(context: Context): FirebaseApp? {
mutex.withLock {
peekFirebaseApp()?.let {
return it
}
val firebaseAppContainer = getFirebaseAppContainer(context)
cachedFirebaseApp = firebaseAppContainer
}
return peekFirebaseApp()
}
}
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer =
withContext(Dispatchers.IO) {
val firebaseApp = FirebaseApp.initializeApp(context)
FirebaseAppContainer(firebaseApp)
}
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)

View File

@ -0,0 +1,135 @@
@file:JvmName("FirebaseCrashReporterKt")
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import androidx.annotation.AnyThread
import co.electriccoin.zcash.crash.android.R
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.spackle.SuspendingLazy
import co.electriccoin.zcash.spackle.Twig
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.installations.FirebaseInstallations
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
/**
* Registers an exception handler with Firebase Crashlytics.
*/
internal class FirebaseCrashReporter(
context: Context
) : CrashReporter {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val initFirebaseJob: Deferred<CrashReporter?> =
analyticsScope.async {
FirebaseCrashReporterImpl.getInstance(context)
}
@AnyThread
override fun reportCaughtException(exception: Throwable) {
initFirebaseJob.invokeOnCompletionWithResult {
it?.reportCaughtException(exception)
}
}
override fun enable() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.enable()
}
}
override fun disableAndDelete() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.disableAndDelete()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Deferred<T>.invokeOnCompletionWithResult(handler: (T) -> Unit) {
invokeOnCompletion {
handler(this.getCompleted())
}
}
/**
* Registers an exception handler with Firebase Crashlytics.
*/
private class FirebaseCrashReporterImpl(
private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebaseInstallations: FirebaseInstallations
) : CrashReporter {
@AnyThread
override fun reportCaughtException(exception: Throwable) {
error(
"Although most of the sensitive model objects implement custom [toString] methods to redact information" +
" if they were to be logged (which includes exceptions), we're encouraged to disable caught exception" +
" reporting to the remote Crashlytics service due to its security risk. Use the the local variant of" +
" the reporter to report caught exception - [LocalCrashReporter]."
)
}
override fun enable() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(true)
}
override fun disableAndDelete() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(false)
firebaseCrashlytics.deleteUnsentReports()
firebaseInstallations.delete()
}
companion object {
/*
* Note there is a tradeoff with the suspending implementation. In order to avoid disk IO
* on the main thread, there is a brief timing gap during application startup where very
* early crashes may be missed. This is a tradeoff we are willing to make in order to avoid
* ANRs.
*/
private val lazyWithArgument =
SuspendingLazy<Context, CrashReporter?> {
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
// Workaround for disk IO on main thread in Firebase initialization
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
return@SuspendingLazy null
}
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
val firebaseCrashlytics =
FirebaseCrashlytics.getInstance().apply {
setCustomKey(
CrashlyticsUserProperties.IS_TEST,
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
)
}
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
} else {
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
null
}
}
suspend fun getInstance(context: Context): CrashReporter? {
return lazyWithArgument.getInstance(context)
}
}
}
internal object CrashlyticsUserProperties {
/**
* Flags a crash as occurring in a test environment. Set automatically to detect Firebase Test Lab and emulator.wtf
*/
const val IS_TEST = "is_test" // $NON-NLS
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
<!-- Expected to be overridden by a resource overlay in the app module, generated
based on the presence of the Firebase API keys -->
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
</resources>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- We want better control over the timing of Firebase initialization -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name=".internal.local.CrashProcessNameContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
<receiver
android:name=".internal.local.ExceptionReceiver"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
</application>
</manifest>

View File

@ -0,0 +1,17 @@
package co.electriccoin.zcash.crash.android.internal
import android.content.Context
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
class ListCrashReportersImpl : ListCrashReporters {
override fun provideReporters(context: Context): List<CrashReporter> {
// To prevent a race condition, register the LocalCrashReporter first.
// FirebaseCrashReporter does some asynchronous registration internally, while
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// and write the default UncaughtExceptionHandler. The only way to ensure
// interleaving doesn't happen is to register the LocalCrashReporter first.
return listOfNotNull(
LocalCrashReporter.getInstance(context),
)
}
}

View File

@ -0,0 +1,39 @@
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import com.google.firebase.FirebaseApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
object FirebaseAppCache {
private val mutex = Mutex()
@Volatile
private var cachedFirebaseApp: FirebaseAppContainer? = null
fun peekFirebaseApp(): FirebaseApp? = cachedFirebaseApp?.firebaseApp
suspend fun getFirebaseApp(context: Context): FirebaseApp? {
mutex.withLock {
peekFirebaseApp()?.let {
return it
}
val firebaseAppContainer = getFirebaseAppContainer(context)
cachedFirebaseApp = firebaseAppContainer
}
return peekFirebaseApp()
}
}
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer =
withContext(Dispatchers.IO) {
val firebaseApp = FirebaseApp.initializeApp(context)
FirebaseAppContainer(firebaseApp)
}
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)

View File

@ -0,0 +1,135 @@
@file:JvmName("FirebaseCrashReporterKt")
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import androidx.annotation.AnyThread
import co.electriccoin.zcash.crash.android.R
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.spackle.SuspendingLazy
import co.electriccoin.zcash.spackle.Twig
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.installations.FirebaseInstallations
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
/**
* Registers an exception handler with Firebase Crashlytics.
*/
internal class FirebaseCrashReporter(
context: Context
) : CrashReporter {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val initFirebaseJob: Deferred<CrashReporter?> =
analyticsScope.async {
FirebaseCrashReporterImpl.getInstance(context)
}
@AnyThread
override fun reportCaughtException(exception: Throwable) {
initFirebaseJob.invokeOnCompletionWithResult {
it?.reportCaughtException(exception)
}
}
override fun enable() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.enable()
}
}
override fun disableAndDelete() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.disableAndDelete()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Deferred<T>.invokeOnCompletionWithResult(handler: (T) -> Unit) {
invokeOnCompletion {
handler(this.getCompleted())
}
}
/**
* Registers an exception handler with Firebase Crashlytics.
*/
private class FirebaseCrashReporterImpl(
private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebaseInstallations: FirebaseInstallations
) : CrashReporter {
@AnyThread
override fun reportCaughtException(exception: Throwable) {
error(
"Although most of the sensitive model objects implement custom [toString] methods to redact information" +
" if they were to be logged (which includes exceptions), we're encouraged to disable caught exception" +
" reporting to the remote Crashlytics service due to its security risk. Use the the local variant of" +
" the reporter to report caught exception - [LocalCrashReporter]."
)
}
override fun enable() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(true)
}
override fun disableAndDelete() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(false)
firebaseCrashlytics.deleteUnsentReports()
firebaseInstallations.delete()
}
companion object {
/*
* Note there is a tradeoff with the suspending implementation. In order to avoid disk IO
* on the main thread, there is a brief timing gap during application startup where very
* early crashes may be missed. This is a tradeoff we are willing to make in order to avoid
* ANRs.
*/
private val lazyWithArgument =
SuspendingLazy<Context, CrashReporter?> {
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
// Workaround for disk IO on main thread in Firebase initialization
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
return@SuspendingLazy null
}
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
val firebaseCrashlytics =
FirebaseCrashlytics.getInstance().apply {
setCustomKey(
CrashlyticsUserProperties.IS_TEST,
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
)
}
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
} else {
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
null
}
}
suspend fun getInstance(context: Context): CrashReporter? {
return lazyWithArgument.getInstance(context)
}
}
}
internal object CrashlyticsUserProperties {
/**
* Flags a crash as occurring in a test environment. Set automatically to detect Firebase Test Lab and emulator.wtf
*/
const val IS_TEST = "is_test" // $NON-NLS
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
<!-- Expected to be overridden by a resource overlay in the app module, generated
based on the presence of the Firebase API keys -->
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
</resources>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- We want better control over the timing of Firebase initialization -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name=".internal.local.CrashProcessNameContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
<receiver
android:name=".internal.local.ExceptionReceiver"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:exported="false"
android:process=":crash" />
</application>
</manifest>

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.crash.android.internal
import android.content.Context
import co.electriccoin.zcash.crash.android.internal.firebase.FirebaseCrashReporter
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
class ListCrashReportersImpl : ListCrashReporters {
override fun provideReporters(context: Context): List<CrashReporter> {
// To prevent a race condition, register the LocalCrashReporter first.
// FirebaseCrashReporter does some asynchronous registration internally, while
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// and write the default UncaughtExceptionHandler. The only way to ensure
// interleaving doesn't happen is to register the LocalCrashReporter first.
return listOfNotNull(
LocalCrashReporter.getInstance(context),
FirebaseCrashReporter(context),
)
}
}

View File

@ -0,0 +1,39 @@
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import com.google.firebase.FirebaseApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
object FirebaseAppCache {
private val mutex = Mutex()
@Volatile
private var cachedFirebaseApp: FirebaseAppContainer? = null
fun peekFirebaseApp(): FirebaseApp? = cachedFirebaseApp?.firebaseApp
suspend fun getFirebaseApp(context: Context): FirebaseApp? {
mutex.withLock {
peekFirebaseApp()?.let {
return it
}
val firebaseAppContainer = getFirebaseAppContainer(context)
cachedFirebaseApp = firebaseAppContainer
}
return peekFirebaseApp()
}
}
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer =
withContext(Dispatchers.IO) {
val firebaseApp = FirebaseApp.initializeApp(context)
FirebaseAppContainer(firebaseApp)
}
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)

View File

@ -0,0 +1,135 @@
@file:JvmName("FirebaseCrashReporterKt")
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import androidx.annotation.AnyThread
import co.electriccoin.zcash.crash.android.R
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.spackle.SuspendingLazy
import co.electriccoin.zcash.spackle.Twig
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.installations.FirebaseInstallations
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
/**
* Registers an exception handler with Firebase Crashlytics.
*/
internal class FirebaseCrashReporter(
context: Context
) : CrashReporter {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val initFirebaseJob: Deferred<CrashReporter?> =
analyticsScope.async {
FirebaseCrashReporterImpl.getInstance(context)
}
@AnyThread
override fun reportCaughtException(exception: Throwable) {
initFirebaseJob.invokeOnCompletionWithResult {
it?.reportCaughtException(exception)
}
}
override fun enable() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.enable()
}
}
override fun disableAndDelete() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.disableAndDelete()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Deferred<T>.invokeOnCompletionWithResult(handler: (T) -> Unit) {
invokeOnCompletion {
handler(this.getCompleted())
}
}
/**
* Registers an exception handler with Firebase Crashlytics.
*/
private class FirebaseCrashReporterImpl(
private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebaseInstallations: FirebaseInstallations
) : CrashReporter {
@AnyThread
override fun reportCaughtException(exception: Throwable) {
error(
"Although most of the sensitive model objects implement custom [toString] methods to redact information" +
" if they were to be logged (which includes exceptions), we're encouraged to disable caught exception" +
" reporting to the remote Crashlytics service due to its security risk. Use the the local variant of" +
" the reporter to report caught exception - [LocalCrashReporter]."
)
}
override fun enable() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(true)
}
override fun disableAndDelete() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(false)
firebaseCrashlytics.deleteUnsentReports()
firebaseInstallations.delete()
}
companion object {
/*
* Note there is a tradeoff with the suspending implementation. In order to avoid disk IO
* on the main thread, there is a brief timing gap during application startup where very
* early crashes may be missed. This is a tradeoff we are willing to make in order to avoid
* ANRs.
*/
private val lazyWithArgument =
SuspendingLazy<Context, CrashReporter?> {
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
// Workaround for disk IO on main thread in Firebase initialization
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
return@SuspendingLazy null
}
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
val firebaseCrashlytics =
FirebaseCrashlytics.getInstance().apply {
setCustomKey(
CrashlyticsUserProperties.IS_TEST,
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
)
}
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
} else {
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
null
}
}
suspend fun getInstance(context: Context): CrashReporter? {
return lazyWithArgument.getInstance(context)
}
}
}
internal object CrashlyticsUserProperties {
/**
* Flags a crash as occurring in a test environment. Set automatically to detect Firebase Test Lab and emulator.wtf
*/
const val IS_TEST = "is_test" // $NON-NLS
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
<!-- Expected to be overridden by a resource overlay in the app module, generated
based on the presence of the Firebase API keys -->
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
</resources>

View File

@ -14,7 +14,6 @@ To enhance security, [OpenID Connect](https://docs.github.com/en/actions/deploym
### Pull request
* Variables
* `ZCASH_SUPPORT_EMAIL_ADDRESS` - Email address for user support requests.
* `FIREBASE_TEST_LAB_PROJECT` - Firebase Test Lab project name.
* Secrets
* `EMULATOR_WTF_API_KEY` - API key for [Emulator.wtf](https://emulator.wtf)
@ -36,8 +35,6 @@ Note that pull requests will create a "release" build with a temporary fake sign
Note that `FIREBASE_DEBUG_JSON_BASE64` and `FIREBASE_RELEASE_JSON_BASE64` are not truly considered secret, as they contain API keys that are embedded in the application. However we are not including them in the repository to reduce accidental pollution of our crash report data from repository forks.
### Release deployment
* Variables
* `ZCASH_SUPPORT_EMAIL_ADDRESS` - Email address for user support requests.
* Secrets
* `GOOGLE_PLAY_CLOUD_PROJECT` - Google Cloud project associated with Google Play.
* `GOOGLE_PLAY_SERVICE_ACCOUNT` - Email address of service account.

View File

@ -0,0 +1 @@
Happy Birthday FOSS Zashi! Welcome to F-droid!

View File

@ -0,0 +1,10 @@
Zashi is a Zcash wallet that keeps your transaction and message history private.
Built and maintained by Electric Coin Co. (ECC), the inventor of Zcash, Zashi is the easiest way to use $ZEC.
Why use the Zashi Zcash wallet?
* You can send and receive ZEC and private memos. (Zashi your mom! Zashi your barista!)
* Its reliable and fast, and it includes the ability to Spend before Sync. (Funds are available before sync completes.)
* The UI is fully updated — simple and clean.
* It supports Sapling and Orchard pools, plus Unified Addresses, a single address type that works across all Zcash pools, transparent and shielded.
* It has built-in mechanisms for user support and developer feedback.

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View File

@ -0,0 +1 @@
Zashi is a Zcash wallet that keeps your transaction and message history private

View File

@ -0,0 +1 @@
Zashi: Zcash Wallet

View File

@ -0,0 +1 @@
¡Feliz Cumpleaños FLOSS Zashi! Bienvenido a F-droid!

View File

@ -0,0 +1,10 @@
Zashi es una billetera de Zcash que mantiene privado el historial de tus transacciones y mensajes.
Creada y gestionada por Electric Coin Co. (ECC), los creadores de Zcash, Zashi es la manera más sencilla de usar $ZEC.
¿Por qué usar la billetera Zashi de Zcash?
* Puedes enviar y recibir ZEC y mensajes privados. (¡Envía Zashi a tu mamá! ¡Envía Zashi a tu barista!)
* Es confiable y rápida, e incluye la función "Gastar antes de Sincronizar" (los fondos están disponibles antes de que se complete la sincronización).
* La interfaz está completamente actualizada: simple y clara.
* Soporta los pools Sapling y Orchard, además de Direcciones Unificadas, un tipo de dirección única que funciona en todos los pools de Zcash, tanto transparentes como protegidos.
* Cuenta con funciones integradas para soporte al usuario y feedback de desarrolladores.

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View File

@ -0,0 +1 @@
Zashi: Una billetera de Zcash que mantiene tus transacciones y mensajes privados

View File

@ -0,0 +1 @@
Zashi: Billetera de Zcash

View File

@ -68,8 +68,8 @@ ZCASH_VERSION_NAME=1.3.3
# available on Google Play. This is useful for testing, or for a forked version of the app.
ZCASH_RELEASE_APP_NAME=Zashi
ZCASH_RELEASE_PACKAGE_NAME=co.electriccoin.zcash
ZCASH_DEBUG_APP_NAME_SUFFIX=" (D)"
ZCASH_SUPPORT_EMAIL_ADDRESS=support@electriccoin.co
ZCASH_DEBUG_APP_NAME_SUFFIX="Debug"
ZCASH_FOSS_APP_NAME_SUFFIX="Foss"
# In-app update priority of the release. It can take values in the range [0, 5], with 5 being the highest priority.
# Defaults to 0. We treat all the values the same, except 5, which is evaluated as [Priority.HIGH],
@ -155,9 +155,11 @@ ANDROID_GRADLE_PLUGIN_VERSION=8.5.0
DETEKT_VERSION=1.23.6
DETEKT_COMPOSE_RULES_VERSION=0.3.15
EMULATOR_WTF_GRADLE_PLUGIN_VERSION=0.16.2
# Handled
FIREBASE_CRASHLYTICS_BUILD_TOOLS_VERSION=2.9.9
FLANK_VERSION=23.10.1
FULLADLE_VERSION=0.17.4
# Handled
GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.4.1
GRADLE_VERSIONS_PLUGIN_VERSION=0.51.0
JGIT_VERSION=6.4.0.202211300538-r
@ -193,7 +195,9 @@ ANDROIDX_UI_AUTOMATOR_VERSION=2.3.0
ANDROIDX_WORK_MANAGER_VERSION=2.9.0
ANDROIDX_BROWSER_VERSION=1.8.0
CORE_LIBRARY_DESUGARING_VERSION=2.1.2
# Handled
FIREBASE_BOM_VERSION_MATCHER=33.1.1
# Handled
GOOGLE_AUTH_LIB_JAVA_VERSION=1.18.0
JACOCO_VERSION=0.8.12
KEYSTONE_VERSION=0.7.10
@ -205,9 +209,11 @@ KOTLINX_SERIALIZABLE_JSON_VERSION=1.6.3
KOVER_VERSION=0.7.3
LOTTIE_VERSION=6.5.0
MARKDOWN_VERSION=0.7.3
# Should we handle?
MLKIT_SCANNING_VERSION=17.3.0
PLAY_APP_UPDATE_VERSION=2.1.0
# We should handle
PLAY_APP_UPDATE_KTX_VERSION=2.1.0
# We should handle
PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0
TINK_VERSION=1.15.0
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0

View File

@ -130,6 +130,7 @@ dependencyResolutionManagement {
}
}
}
// start wtf maven
maven("https://maven.emulator.wtf/releases/") {
if (isRepoRestrictionEnabled) {
content {
@ -137,6 +138,7 @@ dependencyResolutionManagement {
}
}
}
// end wtf maven
maven("https://jitpack.io")
}
@ -183,8 +185,6 @@ dependencyResolutionManagement {
val lottieVersion = extra["LOTTIE_VERSION"].toString()
val markdownVersion = extra["MARKDOWN_VERSION"].toString()
val mlkitScanningVersion = extra["MLKIT_SCANNING_VERSION"].toString()
val playAppUpdateVersion = extra["PLAY_APP_UPDATE_VERSION"].toString()
val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString()
val tinkVersion = extra["TINK_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
@ -250,8 +250,6 @@ dependencyResolutionManagement {
library("lottie", "com.airbnb.android:lottie-compose:$lottieVersion")
library("markdown", "org.jetbrains:markdown:$markdownVersion")
library("mlkit-scanning", "com.google.mlkit:barcode-scanning:$mlkitScanningVersion")
library("play-update", "com.google.android.play:app-update:$playAppUpdateVersion")
library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion")
library("tink", "com.google.crypto.tink:tink-android:$tinkVersion")
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
library("zcash-sdk-incubator", "cash.z.ecc.android:zcash-android-sdk-incubator:$zcashSdkVersion")
@ -335,13 +333,6 @@ dependencyResolutionManagement {
"koin-compose",
)
)
bundle(
"play-update",
listOf(
"play-update",
"play-update-ktx",
)
)
}
}
}

View File

@ -1,3 +1,7 @@
import model.DistributionDimension
import model.BuildType
import model.NetworkDimension
plugins {
id("com.android.test")
kotlin("android")
@ -14,20 +18,22 @@ android {
// to enable benchmarking for emulators, although only a physical device gives real results
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR"
// To simplify module variants, we assume to run benchmarking against mainnet only
missingDimensionStrategy("network", "zcashmainnet")
missingDimensionStrategy(NetworkDimension.DIMENSION_NAME, NetworkDimension.MAINNET.value)
missingDimensionStrategy(DistributionDimension.DIMENSION_NAME, DistributionDimension.STORE.value)
missingDimensionStrategy(DistributionDimension.DIMENSION_NAME, DistributionDimension.FOSS.value)
}
buildTypes {
create("release") {
create(BuildType.RELEASE.value) {
// To provide compatibility with other modules
}
create("benchmark") {
create(BuildType.BENCHMARK.value) {
// We provide the extra benchmark build variants for benchmarking. We still need to support debug
// variants to be compatible with debug variants in other modules, although benchmarking does not allow
// not minified build variants - benchmarking with the debug build variants will fail.
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName(BuildType.DEBUG.value)
matchingFallbacks += listOf(BuildType.RELEASE.value)
}
}
}

View File

@ -1,3 +1,7 @@
import model.DistributionDimension
import model.BuildType
import model.NetworkDimension
plugins {
id("com.android.test")
kotlin("android")
@ -26,17 +30,24 @@ android {
}
// Define the same flavors as in app module
flavorDimensions.add("network")
flavorDimensions += listOf(NetworkDimension.DIMENSION_NAME, DistributionDimension.DIMENSION_NAME)
productFlavors {
create("zcashtestnet") {
dimension = "network"
create(NetworkDimension.TESTNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
}
create("zcashmainnet") {
dimension = "network"
create(NetworkDimension.MAINNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
}
create(DistributionDimension.STORE.value) {
dimension = DistributionDimension.DIMENSION_NAME
}
create(DistributionDimension.FOSS.value) {
dimension = DistributionDimension.DIMENSION_NAME
}
}
buildTypes {
create("release") {
create(BuildType.RELEASE.value) {
// to align with the benchmark module requirement - run against minified application
}
}
@ -66,7 +77,6 @@ dependencies {
implementation(libs.bundles.androidx.test)
implementation(libs.bundles.androidx.compose.core)
implementation(libs.bundles.play.update)
implementation(libs.androidx.compose.test.junit)
implementation(libs.androidx.navigation.compose)

View File

@ -1,111 +0,0 @@
package co.electriccoin.zcash.ui.integration.test.screen.update.viewmodel
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerMock
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.viewmodel.UpdateViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class UpdateViewModelTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<IntegrationTestingActivity>()
private lateinit var viewModel: UpdateViewModel
private lateinit var checker: AppUpdateCheckerMock
private lateinit var initialUpdateInfo: UpdateInfo
@Before
fun setup() {
checker = AppUpdateCheckerMock()
initialUpdateInfo =
UpdateInfoFixture.new(
appUpdateInfo = null,
state = UpdateState.Prepared,
priority = AppUpdateChecker.Priority.LOW,
force = false
)
viewModel =
UpdateViewModel(
composeTestRule.activity.application,
initialUpdateInfo,
checker
)
}
@After
fun cleanup() {
viewModel.viewModelScope.cancel()
}
@Test
@MediumTest
fun validate_result_of_update_methods_calls() =
runTest {
viewModel.checkForAppUpdate()
// Although this test does not copy the real world situation, as the initial and result objects
// should be mostly the same, we test VM proper functionality. VM emits the initial object
// defined in this class, then we expect the result object from the AppUpdateCheckerMock class
// and a newly acquired AppUpdateInfo object.
viewModel.updateInfo.take(4).collectIndexed { index, incomingInfo ->
when (index) {
0 -> {
// checkForAppUpdate initial callback
incomingInfo.also {
assertNull(it.appUpdateInfo)
assertEquals(initialUpdateInfo.state, it.state)
assertEquals(initialUpdateInfo.appUpdateInfo, it.appUpdateInfo)
assertEquals(initialUpdateInfo.priority, it.priority)
assertEquals(initialUpdateInfo.state, it.state)
assertEquals(initialUpdateInfo.isForce, it.isForce)
}
}
1 -> {
// checkForAppUpdate result callback
incomingInfo.also {
assertNotNull(it.appUpdateInfo)
assertEquals(AppUpdateCheckerMock.resultUpdateInfo.state, it.state)
assertEquals(AppUpdateCheckerMock.resultUpdateInfo.priority, it.priority)
assertEquals(AppUpdateCheckerMock.resultUpdateInfo.isForce, it.isForce)
}
// now we can start the update
viewModel.goForUpdate(composeTestRule.activity, incomingInfo.appUpdateInfo!!)
}
2 -> {
// goForUpdate initial callback
assertNotNull(incomingInfo.appUpdateInfo)
assertEquals(UpdateState.Running, incomingInfo.state)
}
3 -> {
// goForUpdate result callback
assertNotNull(incomingInfo.appUpdateInfo)
assertEquals(UpdateState.Done, incomingInfo.state)
}
}
}
}
}

View File

@ -1,4 +1,6 @@
import com.android.build.api.variant.BuildConfigField
import model.DistributionDimension
import model.NetworkDimension
plugins {
id("com.android.library")
@ -81,6 +83,26 @@ android {
)
}
}
flavorDimensions += listOf(NetworkDimension.DIMENSION_NAME, DistributionDimension.DIMENSION_NAME)
productFlavors {
create(NetworkDimension.TESTNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
}
create(NetworkDimension.MAINNET.value) {
dimension = NetworkDimension.DIMENSION_NAME
}
create(DistributionDimension.STORE.value) {
dimension = DistributionDimension.DIMENSION_NAME
}
create(DistributionDimension.FOSS.value) {
dimension = DistributionDimension.DIMENSION_NAME
}
}
}
androidComponents {
@ -136,7 +158,6 @@ dependencies {
implementation(libs.bundles.androidx.compose.core)
implementation(libs.bundles.androidx.compose.extended)
api(libs.bundles.koin)
implementation(libs.bundles.play.update)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
@ -144,7 +165,7 @@ dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.immutable)
implementation(libs.kotlinx.serializable.json)
implementation(libs.mlkit.scanning)
"storeImplementation"(libs.mlkit.scanning)
api(libs.zcash.sdk)
implementation(libs.zcash.sdk.incubator)
implementation(libs.zcash.bip39)

View File

@ -41,7 +41,6 @@ class BalancesTestSetup(
showStatusDialog = null,
onStatusClick = {},
snackbarHostState = SnackbarHostState(),
isUpdateAvailable = false,
isShowingErrorDialog = false,
setShowErrorDialog = {},
onContactSupport = {},

View File

@ -31,7 +31,6 @@ class WalletDisplayValuesTest {
WalletDisplayValues.getNextValues(
context = getAppContext(),
walletSnapshot = walletSnapshot,
isUpdateAvailable = false
)
assertNotNull(values)

View File

@ -1,23 +0,0 @@
package co.electriccoin.zcash.ui.screen.update.fixture
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import org.junit.Test
import kotlin.test.assertEquals
class UpdateInfoFixtureTest {
companion object {
val updateInfo = UpdateInfoFixture.new(appUpdateInfo = null)
}
@Test
@SmallTest
fun fixture_result_test() {
updateInfo.also {
assertEquals(it.priority, UpdateInfoFixture.INITIAL_PRIORITY)
assertEquals(it.isForce, UpdateInfoFixture.INITIAL_FORCE)
assertEquals(it.state, UpdateInfoFixture.INITIAL_STATE)
assertEquals(it.appUpdateInfo, null)
}
}
}

Some files were not shown because too many files have changed in this diff Show More