Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bd8761b
feat: implement thread-safe failure mode caching
Apr 21, 2026
e7b513c
Update settings.graddle to support local and ci repo usage for mini-sdk
Apr 21, 2026
4bf125c
Fix ApproovService retrofit compilation, AndroidManifest package, and…
Apr 21, 2026
2e51ea8
Fix clean checkout test compilation without mini-sdk
Apr 22, 2026
bb77725
Handle uninitialized SDK states gracefully and obsolete prefetch
Apr 22, 2026
00a3d18
Expose isApproovEnabled publicly and update REFERENCE.md
Apr 22, 2026
1984624
Fix misleading error log in bypass mode
Apr 22, 2026
b04b450
Use SystemClock.elapsedRealtime for failure cache TTL
Apr 22, 2026
bc7bf73
Fix FileInputStream leak in settings.gradle
Apr 22, 2026
51e30f9
feat: add public setter for failure cache TTL
Apr 23, 2026
d8a7076
chore: add debug logging for MITM caching
Apr 23, 2026
be29f7e
Docs: Document setFailureCacheTTL in REFERENCE and CHANGELOG
Apr 24, 2026
ede66dd
feat: enhance ApproovService initialization and caching behavior
adriantuk Apr 27, 2026
1d9768f
Merge remote changes: URL-scoped failure cache, bypass mode guards, a…
Apr 28, 2026
131e257
Fix failure cache to be global instead of URL-scoped
Apr 28, 2026
a7b192a
chore: bump bouncycastle bcprov-jdk15to18 1.80 → 1.84
Apr 30, 2026
d3938b4
feat: double-checked locking gate for failure cache SDK calls
Apr 30, 2026
f26bc97
fix: expect ApproovNetworkException in failure cache test
Apr 30, 2026
6e1db65
chore: upgrade GitHub Actions to Node.js 24 (v5/v7)
Apr 30, 2026
ea1ef4b
Fix docs to align with input in milliseconds
Apr 30, 2026
b53ce80
Clarify behaviour of gating for failure fetches
Apr 30, 2026
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
291 changes: 291 additions & 0 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
name: Build and Test

on:
push:
pull_request:

jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
WORKSPACE: "${{ github.workspace }}"
GIT_BRANCH: "${{ github.ref }}"
CURRENT_TAG: "${{ github.ref_name }}"
# Worker endpoints: consumed from GitHub organisation variables first.
# If the org variable is not set the value is an empty string; the probe
# step below falls back to the same hardcoded defaults that are baked into
# the mini-sdk (MiniAttesterConfig / Approov.m), keeping them in sync.
# Worker management (create / redeploy) logic is centralized in the
# core-service-layers-testing script, but invoked here when needed.
TESTING_REPLY_URL: ${{ vars.TESTING_REPLY_URL }}
TESTING_REPLY_URL_UNPROTECTED: ${{ vars.TESTING_REPLY_URL_UNPROTECTED }}
# Required for the redeploy script invocation on failure
CLOUDFLARE_API_TOKEN_WORKERS_DEV: ${{ secrets.CLOUDFLARE_API_TOKEN_WORKERS_DEV }}
CLOUDFLARE_ACCOUNT_ID_WORKERS_DEV: ${{ secrets.CLOUDFLARE_ACCOUNT_ID_WORKERS_DEV }}

steps:
# -----------------------------------------------------------------------
# 1. Checkout this repository
# -----------------------------------------------------------------------
- name: Set up Git
run: git config --global --add safe.directory '*'

- name: Checkout approov-service-retrofit
uses: actions/checkout@v5
with:
path: approov-service-retrofit

# -----------------------------------------------------------------------
# 2. Clone core-service-layers-testing as a sibling directory
# settings.gradle expects: ../core-service-layers-testing/...
# so both repos must sit under the same parent ($GITHUB_WORKSPACE).
# -----------------------------------------------------------------------
- name: Checkout core-service-layers-testing
uses: actions/checkout@v5
with:
repository: approov/core-service-layers-testing
token: ${{ secrets.CORE_SERVICE_LAYERS_TESTING_PAT }}
path: core-service-layers-testing

# -----------------------------------------------------------------------
# 3. Verify worker endpoints (with auto-redeploy)
#
# URLs are resolved with the following precedence (mirrors the mini-sdk
# fallback logic in MiniAttesterConfig / Approov.m):
# 1. GitHub org variable (TESTING_REPLY_URL / _UNPROTECTED)
# 2. Mini-SDK hardcoded defaults (replay.ivol.workers.dev / ...)
#
# If the workers are unavailable, this step invokes the management
# script in core-service-layers-testing to redeploy them.
# -----------------------------------------------------------------------
- name: Verify worker endpoints
run: |
# ── URL resolution ──────────────────────────────────────────────────
PROTECTED_URL="${TESTING_REPLY_URL:-https://replay.ivol.workers.dev}"
UNPROTECTED_URL="${TESTING_REPLY_URL_UNPROTECTED:-https://replay-unprotected.ivol.workers.dev}"

echo "TESTING_REPLY_URL=$PROTECTED_URL" >> "$GITHUB_ENV"
echo "TESTING_REPLY_URL_UNPROTECTED=$UNPROTECTED_URL" >> "$GITHUB_ENV"

# ── Probe function ──────────────────────────────────────────────────
probe_worker() {
local url="$1"
curl --silent --show-error --fail --max-time 10 \
-X POST "$url" \
-H "Content-Type: application/json" \
-d '{"check":"probe"}' | grep -q '"body"'
}

echo "==> Probing workers..."
if probe_worker "$PROTECTED_URL" && probe_worker "$UNPROTECTED_URL"; then
echo " All workers are healthy."
exit 0
fi

echo " WARNING: One or more workers are down. Attempting redeploy..."

# ── Invoke redeploy script ──────────────────────────────────────────
# The script is in the sibling directory cloned in Step 2.
# It uses the CLOUDFLARE_* env variables defined at the job level.
./core-service-layers-testing/cloudflare-workers/redeploy-workers.sh

Comment thread
ivolz marked this conversation as resolved.
echo "==> Verifying after redeploy..."
# Try up to 3 times with a 5s delay between attempts
for i in {1..3}; do
if probe_worker "$PROTECTED_URL" && probe_worker "$UNPROTECTED_URL"; then
echo " Workers successfully restored."
exit 0
fi
echo " Waiting for propagation (attempt $i/3)..."
sleep 5
done

echo "ERROR: Workers are still unreachable after redeployment effort."
exit 1

# -----------------------------------------------------------------------
# 4. Java / Android toolchain setup
# -----------------------------------------------------------------------
- name: Set Up Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'

- name: Install Android SDK command-line tools
run: |
sudo apt-get update -q
sudo apt-get install -y -q unzip curl
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -o android-sdk.zip \
https://dl.google.com/android/repository/commandlinetools-linux-9123335_latest.zip
unzip -q android-sdk.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/tools"
rm android-sdk.zip
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "$ANDROID_HOME/cmdline-tools/tools/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator" >> "$GITHUB_PATH"

- name: Accept Android SDK licenses
run: yes | sdkmanager --licenses || true

- name: Install required Android SDK packages
run: |
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"

# -----------------------------------------------------------------------
# 5. Build and run tests
# Gradle is invoked from inside the checked-out service directory.
# The mini-sdk is already available via the sibling path wired into
# settings.gradle — no extra configuration needed here.
# -----------------------------------------------------------------------
- name: Build AAR
working-directory: approov-service-retrofit
run: ./gradlew assembleRelease

- name: Run unit tests
working-directory: approov-service-retrofit
env:
TESTING_REPLY_URL: ${{ env.TESTING_REPLY_URL }}
TESTING_REPLY_URL_UNPROTECTED: ${{ env.TESTING_REPLY_URL_UNPROTECTED }}
run: ./gradlew test

# -----------------------------------------------------------------------
# 6. Print test summary to the console and to the Actions Job Summary
# Parses every JUnit XML produced by Gradle and emits:
# • a formatted table in the step log
# • a markdown table in the Job Summary (Actions UI → Summary tab)
# -----------------------------------------------------------------------
- name: Print test summary
if: always()
working-directory: approov-service-retrofit
run: |
python3 - <<'EOF'
import os, sys, glob, xml.etree.ElementTree as ET
from collections import defaultdict

PASS = "\u2705"
FAIL = "\u274c"
SKIP = "\u23ed\ufe0f"
ERROR = "\u26a0\ufe0f"

results_root = "approov-service/build/test-results"
xml_files = sorted(glob.glob(f"{results_root}/**/*.xml", recursive=True))

if not xml_files:
print("No test-result XML files found.")
sys.exit(0)

# Group suites by variant directory name (testDebugUnitTest, etc.)
by_variant = defaultdict(list)
for path in xml_files:
variant = os.path.basename(os.path.dirname(path))
try:
root = ET.parse(path).getroot()
except ET.ParseError:
continue
suite = {
"name": root.get("name", os.path.basename(path)),
"tests": int(root.get("tests", 0)),
"failures": int(root.get("failures", 0)),
"errors": int(root.get("errors", 0)),
"skipped": int(root.get("skipped", 0)),
"time": float(root.get("time", 0)),
"cases": [],
}
for tc in root.findall("testcase"):
status = PASS
detail = ""
if tc.find("failure") is not None:
status = FAIL
detail = (tc.find("failure").get("message") or "").split("\n")[0][:120]
elif tc.find("error") is not None:
status = ERROR
detail = (tc.find("error").get("message") or "").split("\n")[0][:120]
elif tc.find("skipped") is not None:
status = SKIP
suite["cases"].append({
"name": tc.get("name", "?"),
"time": float(tc.get("time", 0)),
"status": status,
"detail": detail,
})
by_variant[variant].append(suite)

# ── Console output ──────────────────────────────────────────────────
overall_ok = True
for variant, suites in sorted(by_variant.items()):
total_t = sum(s["tests"] for s in suites)
total_f = sum(s["failures"] + s["errors"] for s in suites)
total_s = sum(s["skipped"] for s in suites)
total_p = total_t - total_f - total_s
icon = PASS if total_f == 0 else FAIL
if total_f > 0:
overall_ok = False
print()
print(f"{'='*70}")
print(f" {icon} {variant} | {total_p}/{total_t} passed "
f"| {total_f} failed | {total_s} skipped")
print(f"{'='*70}")
for suite in suites:
short = suite["name"].split(".")[-1]
p = suite["tests"] - suite["failures"] - suite["errors"] - suite["skipped"]
f = suite["failures"] + suite["errors"]
print(f" {PASS if f==0 else FAIL} {short:<55} {p}/{suite['tests']} ({suite['time']:.2f}s)")
for case in suite["cases"]:
if case["status"] != PASS:
print(f" {case['status']} {case['name']}")
if case["detail"]:
print(f" {case['detail']}")
print()

# ── GitHub Job Summary (markdown) ───────────────────────────────────
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "")
if not summary_path:
sys.exit(0 if overall_ok else 1)

with open(summary_path, "a") as md:
md.write("## \U0001f9ea Unit Test Results\n\n")
for variant, suites in sorted(by_variant.items()):
total_t = sum(s["tests"] for s in suites)
total_f = sum(s["failures"] + s["errors"] for s in suites)
total_s = sum(s["skipped"] for s in suites)
total_p = total_t - total_f - total_s
badge = "\U0001f7e2 PASSED" if total_f == 0 else "\U0001f534 FAILED"
md.write(f"### {variant} &nbsp; {badge}\n\n")
md.write(f"> **{total_p} passed** &nbsp;|&nbsp; "
f"**{total_f} failed** &nbsp;|&nbsp; "
f"**{total_s} skipped** &nbsp;|&nbsp; "
f"**{total_t} total**\n\n")
md.write("| Status | Test suite | Tests | Failed | Skipped | Time |\n")
md.write("|--------|------------|------:|-------:|--------:|-----:|\n")
for suite in suites:
short = suite["name"].split(".")[-1]
f = suite["failures"] + suite["errors"]
icon = "\U0001f7e2" if f == 0 else "\U0001f534"
md.write(f"| {icon} | `{short}` | {suite['tests']} | {f} | {suite['skipped']} | {suite['time']:.2f}s |\n")
# Expand failures inline
failures = [c for s in suites for c in s["cases"] if c["status"] in (FAIL, ERROR)]
if failures:
md.write("\n<details><summary>Failed tests</summary>\n\n")
for c in failures:
md.write(f"- {c['status']} `{c['name']}`")
if c["detail"]:
md.write(f" \n > {c['detail']}")
md.write("\n")
md.write("\n</details>\n")
md.write("\n")

sys.exit(0 if overall_ok else 1)
EOF

# -----------------------------------------------------------------------
# 7. Upload HTML reports as a downloadable artifact
# -----------------------------------------------------------------------
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ github.run_number }}
path: approov-service-retrofit/approov-service/build/reports/tests/
retention-days: 14
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this package will be documented in this file.
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.


## [3.5.6] - 2026-04-21

### Added
- Thread-safe failure mode caching for the interceptor path. When the platform SDK returns a failure status (`NO_NETWORK`, `POOR_NETWORK`, `MITM_DETECTED`, `NO_APPROOV_SERVICE`), the result is cached for 0.5 seconds. Subsequent requests within that window return the cached failure instantly, avoiding redundant ~1s SDK calls. Success is never cached.
- Added `ApproovService.isInitialized()` to expose the service-layer initialization state.
- Integrated a localized testing framework for comprehensive service layer verification.
- Added extensive test coverage for core service flows, including initialization, token management, and request mutation.

### Changed
- Initializing with an empty config string now keeps the service layer initialized while forwarding requests without Approov processing.
- Initializing first with an empty config string and later with a valid non-empty config string now enables Approov at runtime instead of being rejected as a different-configuration initialization.
- Tightened the initialization guard so only actual `reinit...` comments bypass same-config enforcement.

### Fixed
- Added explicit cross-service-layer initialization handling so a benign same-config already-initialized native SDK outcome is tolerated, while real different-configuration failures still surface as initialization errors.
Comment thread
ivolz marked this conversation as resolved.
- Updated the build manifest to support flexible dependency resolution for verification suites.

## [3.5.5] - 2026-03-25

### Added
Expand All @@ -23,4 +40,4 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver
### Deprecated
- ApproovInterceptorExtensions in favor of ApproovServiceMutator.
- setProceedOnNetworkFail() and getProceedOnNetworkFail() in favor of setServiceMutator.
- prefetch() is now automatically called when the service is initialized.
- prefetch() is obsolete and is now a no-op. The underlying Approov SDK manages prefetching automatically.
32 changes: 28 additions & 4 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ fun initialize(context: Context, config: String)

The [application context](https://developer.android.com/reference/android/content/Context#getApplicationContext()) must be provided using the `context` parameter.

It is possible to pass an empty `config` string to indicate that no initialization is required. Only do this if you are also using a different Approov service layer in your app (which will use the same underlying Approov SDK) and this will have been initialized first.
It is possible to pass an empty `config` string to indicate that no initialization of the underlying native Approov SDK is required. This initializes the service layer in a bypass mode, allowing you to obtain a standard, non-Approov protected Retrofit client. If you attempt to use any direct native Approov SDK functions (such as `fetchToken` or `precheck`) while bypassed, an `ApproovException` will be thrown. You may later call `initialize` again with a valid `config` string to enable full Approov protection from that point onward.

An alternative initialization function allows to provide further options in the `comment` parameter. Please refer to the [Approov SDK documentation](https://approov.io/docs/latest/approov-direct-sdk-integration/#sdk-initialization-options) for details.

Expand All @@ -46,6 +46,32 @@ void initialize(Context context, String config, String comment)
fun initialize(context: Context, config: String, comment: String)
```

## isInitialized
Indicates whether the Approov service layer has been initialized.

**Java:**
```Java
boolean isInitialized()
```

**Kotlin:**
```kotlin
fun isInitialized(): Boolean
```

## isApproovEnabled
Indicates whether the underlying native Approov SDK is enabled and active. If the service layer was initialized with an empty configuration string, this will return `false`.

**Java:**
```Java
boolean isApproovEnabled()
```

**Kotlin:**
```kotlin
fun isApproovEnabled(): Boolean
```

## setApproovInterceptorExtensions

**OBSOLETED COMPATIBILITY API**: Use `setServiceMutator` instead for all new integrations, including message signing.
Expand Down Expand Up @@ -281,9 +307,7 @@ fun removeExclusionURLRegex(urlRegex: String)
```

## prefetch
Allows an Approov fetch operation to be performed as early as possible. This permits a token or secure strings to be available while an application might be loading resources or is awaiting user input. Since the initial fetch is the most expensive the prefetch can hide the most latency.

**DEPRECATED**: This method is now automatically called when the service is initialized.
**OBSOLETE**: This method is obsolete and is now a no-op. The underlying Approov SDK manages prefetching automatically.

**Java:**
```Java
Expand Down
Loading
Loading