Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ jobs:
- name: Run unit tests
run: ./gradlew testDebugUnitTest

- name: Generate code coverage report (JaCoCo)
run: ./gradlew jacocoTestReport

- name: Check code coverage threshold (30% minimum)
run: |
python3 << 'EOF'
import re
import sys

# Extract coverage percentage from JaCoCo HTML report
with open('android/app/build/reports/jacoco/jacocoTestReport/html/index.html', 'r') as f:
content = f.read()
# Look for pattern like "Total<br />20%"
match = re.search(r'Total[^>]*>\s*(\d+)%', content, re.IGNORECASE)
if match:
coverage = int(match.group(1))
print(f"Code coverage: {coverage}%")
if coverage < 30:
print(f"::warning::Code coverage ({coverage}%) is below 30% threshold. Please add tests to improve coverage.")
else:
print(f"✓ Code coverage meets minimum threshold (30%)")
else:
print("::error::Could not parse coverage percentage from report")
sys.exit(1)
EOF

- name: Verify Paparazzi screenshots
run: ./gradlew verifyPaparazziDebug

Expand Down Expand Up @@ -186,6 +212,14 @@ jobs:
path: android/app/build/reports/tests/testDebugUnitTest
if-no-files-found: ignore

- name: Upload code coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: android-coverage-report
path: android/app/build/reports/jacoco/jacocoTestReport
if-no-files-found: warn

- name: Upload Paparazzi diff report
if: failure()
uses: actions/upload-artifact@v4
Expand Down
34 changes: 33 additions & 1 deletion .github/workflows/ios-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,39 @@ jobs:
TEST_RUNNER_CI=true xcodebuild test \
-scheme EthosProtocol-Package \
-destination "platform=iOS Simulator,id=${{ steps.simulator.outputs.udid }}" \
-skipMacroValidation
-skipMacroValidation \
-enableCodeCoverage YES

- name: Collect code coverage (SPM tests)
working-directory: ios/EthosProtocol
run: |
xcrun llvm-cov export \
-ignore-filename-regex=".build" \
-json \
-instr-profile="$(find ~/Library/Developer/Xcode/DerivedData -name "default.profdata" -type f -print -quit)" \
"$(find ~/Library/Developer/Xcode/DerivedData -name "EthosProtocol" -type f -print -quit)" \
> coverage.json 2>/dev/null || echo "::warning::Code coverage data collection skipped (profdata not found)"

- name: Check code coverage threshold (40% minimum for SPM)
working-directory: ios/EthosProtocol
run: |
python3 << 'EOF'
import json
import sys

try:
with open('coverage.json', 'r') as f:
data = json.load(f)
if 'data' in data and len(data['data']) > 0:
coverage = data['data'][0].get('totals', {}).get('lines', {}).get('percent', 0)
print(f"SPM code coverage: {coverage:.1f}%")
if coverage < 40:
print(f"::warning::Code coverage ({coverage:.1f}%) is below 40% threshold. Please add tests.")
else:
print(f"✓ Code coverage meets minimum threshold (40%)")
except (FileNotFoundError, json.JSONDecodeError, KeyError):
print("::info::Code coverage data not available (profdata or coverage.json missing)")
EOF

- name: Assert VaultWebSocketClient stub is gone (#179)
# Fails the build if any Swift source file still references the deleted
Expand Down
6 changes: 3 additions & 3 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document tracks the implementation status of every user-facing feature acro
the iOS and Android clients. Update it whenever a platform-specific change is made
(see [Contributing](#contributing)).

Last audited: 2026-07-27
Last audited: 2026-08-31

---

Expand Down Expand Up @@ -66,8 +66,8 @@ Last audited: 2026-07-27
| **Offline support** | | | |
| Network connectivity monitor | ✅ | ✅ | |
| Offline read cache (SHA-256 keyed) | ✅ | ✅ | |
| Offline check-in queue + WorkManager retry | | ✅ | iOS has no persistent offline queue; mutations fail with an error banner |
| Offline queue badge / notification | | ✅ | |
| Offline check-in queue + WorkManager retry | | ✅ | iOS: PendingCheckInStore + CheckInSyncTask; Android: PendingActionDao + WorkManager |
| Offline queue badge / notification | | ✅ | iOS: queuedCheckInCount in Stores; Android: BadgeService broadcasts queue updates | |
| **Widget** | | | |
| Home-screen vault TTL widget | ✅ | ✅ | iOS: WidgetKit TTLWidget; Android: VaultStatusWidget (Glance) |
| TTL-aware refresh policy | ✅ | ❌ | Android widget polls at a fixed interval; no urgency scaling (#TBD) |
Expand Down
44 changes: 44 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.paparazzi)
jacoco
}

// Release signing credentials come from the environment (CI) or gradle.properties
Expand Down Expand Up @@ -281,3 +282,46 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-accessibility:3.6.1")
androidTestImplementation("com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.0.0")
}

// JaCoCo code coverage configuration
jacoco {
toolVersion = "0.8.11"
}

tasks.withType<Test>().configureEach {
jacoco {
isIncludeNoLocationClasses = true
}
}

task<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.testDebugUnitTest)
group = "Coverage"
description = "Generate JaCoCo coverage report for unit tests"

reports {
xml.required = true
html.required = true
csv.required = false
}

sourceDirectories.setFrom(
files(
"${project.projectDir}/src/main/java",
"${project.projectDir}/src/main/kotlin"
)
)

classDirectories.setFrom(
files(
fileTree("${project.buildDir}/intermediates/classes/debug/"),
fileTree("${project.buildDir}/tmp/kotlin-classes/debug/")
)
)

executionData.setFrom(
files(
"${project.buildDir}/jacoco/testDebugUnitTest.exec"
)
)
}
124 changes: 124 additions & 0 deletions android/app/src/test/java/com/ethosprotocol/RetryPolicyTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test

// Simulated network error types for chaos testing packet-loss scenarios.
sealed class NetworkError(override val message: String) : Exception(message) {
object TruncatedResponse : NetworkError("Incomplete response body")
object ConnectionReset : NetworkError("Connection reset by peer")
object SocketTimeout : NetworkError("Socket timeout")
object EOF : NetworkError("Unexpected end of stream")
}

class RetryPolicyTest {

// A Random that always returns the requested upper bound minus one, i.e. the
Expand Down Expand Up @@ -80,4 +88,120 @@ class RetryPolicyTest {
}
}
}

// MARK: - Chaos Testing: Simulated Packet Loss

@Test
fun `withRetry handles truncated response and recovers on retry`() = runTest {
val delays = mutableListOf<Long>()
val policy = RetryPolicy(
maxAttempts = 4,
baseDelayMillis = 100,
sleep = { delays.add(it) },
random = maxJitterRandom()
)
var attempts = 0

val result = withRetry(policy, isRetryable = { error ->
error is NetworkError.TruncatedResponse
}) {
attempts++
if (attempts < 3) throw NetworkError.TruncatedResponse else "recovered"
}

assertEquals("recovered", result)
assertEquals(3, attempts)
assertEquals(2, delays.size)
}

@Test
fun `withRetry handles connection reset and recovers on retry`() = runTest {
val delays = mutableListOf<Long>()
val policy = RetryPolicy(
maxAttempts = 4,
baseDelayMillis = 100,
sleep = { delays.add(it) },
random = maxJitterRandom()
)
var attempts = 0

val result = withRetry(policy, isRetryable = { error ->
error is NetworkError.ConnectionReset
}) {
attempts++
if (attempts < 2) throw NetworkError.ConnectionReset else "connection_restored"
}

assertEquals("connection_restored", result)
assertEquals(2, attempts)
}

@Test
fun `withRetry does not retry non-transient network errors`() = runTest {
val policy = RetryPolicy(
maxAttempts = 3,
baseDelayMillis = 100,
sleep = {},
random = Random.Default
)
var attempts = 0

try {
withRetry(policy, isRetryable = { error ->
error is NetworkError.ConnectionReset
}) {
attempts++
throw NetworkError.SocketTimeout("timeout")
}
fail("Should have thrown SocketTimeout")
} catch (e: NetworkError.SocketTimeout) {
assertEquals(1, attempts, "Should not retry non-retryable errors")
}
}

@Test
fun `withRetry does not double-submit mutating requests`() = runTest {
val delays = mutableListOf<Long>()
val policy = RetryPolicy(
maxAttempts = 3,
baseDelayMillis = 100,
sleep = { delays.add(it) },
random = maxJitterRandom()
)
var postCount = 0

val result = withRetry(policy, isRetryable = { error ->
// Only retry transient network errors, not idempotency violations
error is NetworkError.ConnectionReset
}) {
postCount++
if (postCount < 2) throw NetworkError.ConnectionReset else "check_in_recorded"
}

assertEquals("check_in_recorded", result)
assertEquals(2, postCount, "Mutating request should only be submitted twice")
}

@Test
fun `withRetry respects max attempts on persistent socket timeouts`() = runTest {
val policy = RetryPolicy(
maxAttempts = 2,
baseDelayMillis = 100,
sleep = {},
random = Random.Default
)
var attempts = 0

try {
withRetry(policy, isRetryable = { error ->
error is NetworkError.SocketTimeout
}) {
attempts++
throw NetworkError.SocketTimeout("persistent timeout")
}
fail("Should have thrown after exhausting attempts")
} catch (e: NetworkError.SocketTimeout) {
assertEquals(2, attempts, "Should respect maxAttempts limit")
}
}
}
10 changes: 7 additions & 3 deletions android/app/src/test/java/com/ethosprotocol/ScreenshotTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ import org.junit.Test
* - VaultListScreen (empty state)
* - VaultListScreen (populated)
* - AuthScreen
* - DepositScreen
* - WithdrawScreen
* - BeneficiaryAcceptanceScreen
* - DepositScreen (with light and dark mode coverage)
* - WithdrawScreen (with light and dark mode coverage)
* - ManageBeneficiaryScreen (with light and dark mode coverage, when implemented in #87)
* - BeneficiaryAcceptanceScreen (with light and dark mode coverage)
* - VaultDeepLinkScreen (check-in action)
*
* Issue #296: Deposit, Withdraw, and Manage-Beneficiary snapshots are reconciled
* against pre-existing golden images to ensure they match the current implementation.
*/

// ---------------------------------------------------------------------------
Expand Down
Loading