diff --git a/.github/workflows/Build_PlayIntegrityAPI.yml b/.github/workflows/Build_PlayIntegrityAPI.yml new file mode 100644 index 00000000..7cc066c0 --- /dev/null +++ b/.github/workflows/Build_PlayIntegrityAPI.yml @@ -0,0 +1,105 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: Build PlayIntegrityAPI + +on: + push: + branches: [ '*' ] + pull_request: + branches: [ '*' ] + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + sample: ${{ steps.filter.outputs.sample }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + id: filter + with: + filters: | + sample: + - 'PlayIntegrityAPI/**' + - '.github/workflows/Build_PlayIntegrityAPI.yml' + + build-android: + name: Build PlayIntegrityAPI (Android Client) + needs: changes + if: ${{ needs.changes.outputs.sample == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + + defaults: + run: + working-directory: PlayIntegrityAPI/android-client + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.PAT || github.token }} + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Build App, Unit Tests, and Lint + run: ./gradlew assembleEmulatorDebug assembleEmulatorDebugAndroidTest testEmulatorDebugUnitTest --stacktrace + + - name: Verify UI Screenshots + run: ./gradlew validateEmulatorDebugScreenshotTest --stacktrace + + build-node: + name: Build PlayIntegrityAPI (Node.js Server) + needs: changes + if: ${{ needs.changes.outputs.sample == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + + defaults: + run: + working-directory: PlayIntegrityAPI/node-server + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.PAT || github.token }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: PlayIntegrityAPI/node-server/package-lock.json + + - name: Install Dependencies + run: npm ci + + - name: Run Tests + run: npm test diff --git a/PlayIntegrityAPI/README.md b/PlayIntegrityAPI/README.md new file mode 100644 index 00000000..549d5f0a --- /dev/null +++ b/PlayIntegrityAPI/README.md @@ -0,0 +1,708 @@ +# Play Integrity API: E2E Sample app + +> [!NOTE] +> Disclaimer: Non-Goals +> +> This project is designed for demonstration and educational purposes to provide +> a blueprint for technical integration. It is not the goal of this sample app +> to provide a production-ready anti-abuse strategy. +> +> While the sample demonstrates best practices for token handling and +> server-side verification, it is not a substitute for a comprehensive security +> audit. Developers should treat the Play Integrity API as one signal within a +> broader, multi-layered anti-abuse strategy tailored to their specific business +> risks. + +# Setup + +To run the Play Integrity API Canonical Sample end-to-end, you need to configure +a Google Cloud project, register your app in the Google Play Console, and set up +both the local Node.js server and the Android client. + +### Prerequisites + +* [Node.js](https://nodejs.org/en) v18 or higher installed. +* The latest version of [Android Studio](https://developer.android.com/studio) + installed. +* A Google Play Developer account. +* A Google Cloud account. + +## Step 1: Configure Google Cloud & Play Console + +First, establish the connection between your Google Cloud project and your +Google Play app entry. + +1. Open the [Google Cloud Console](https://console.cloud.google.com/) and + create a new project. +2. Navigate to **APIs & Services** \> **Library**, search for the **Google Play + Integrity API**, and click **Enable**. +3. Open the [Google Play Console](https://play.google.com/console/) and create + a new app entry. + * *Note: Choose your package name carefully. You will use this exact + package name to configure both the Android client and the Node.js server + later.* +4. In the Play Console left navigation menu, select **Protected with Play**, + and then click **Get Started** on the **Play Integrity API** card. +5. Follow the on-screen instructions to link the Google Cloud project you + created in step 1\. +6. Enable the population of the following + [optional verdicts](https://developer.android.com/google/play/integrity/verdicts#optional-device-labels) + within the same section: + * `MEETS_STRONG_INTEGRITY` + * `MEETS_BASIC_INTEGRITY` + * Device attributes + * App access risk + * Play Protect + +## Step 2: Generate Service Account Credentials + +Your local server needs credentials to securely communicate with Google Cloud. + +1. In the Google Cloud Console, navigate to **IAM & Admin** \> **Service + Accounts**. +2. Click **Create Service Account**. (Default settings are fine; no special + roles are required). +3. Click on your newly created Service Account, navigate to the **Keys** tab, + and select **Add Key** \> **Create new key**. +4. Select **JSON** as the key type and click **Create** to download the + credentials file to your machine. + +## Step 3: Download the Project + +Clone the repository containing the sample code to your local machine. + +```shell +git clone https://github.com/android/security-samples.git +cd security-samples +``` + +## Step 4: Local Server Setup + +Configure and run the Node.js backend. + +1. Navigate to the server directory: `cd PlayIntegrityAPI/node-server` +2. Install the required dependencies: `npm install` +3. Move the downloaded JSON credentials file from Step 2 into the root of the + `node-server` directory. +4. Rename the file to `google-credentials.json` + * *Note: This filename is listed in `.gitignore` to prevent accidental + credential leaks* +5. Create a file named `.env` in the root of the `node-server` directory and + define the following variables: + * `PACKAGE_NAME="com.your.package.name"` \# Use the package name of the + Play Console app entry created in Step 1 + * `GOOGLE_CREDENTIALS_PATH="./google-credentials.json"` +6. Start the server: `node app.js` + +## Step 5: Android Client Setup + +Configure the Android app to communicate with your local server and your +specific Google Cloud project. + +1. Open the `PlayIntegrityAPI/android-client` directory using Android Studio +2. Open the `local.properties` file in the project root and add your Google + Cloud project number: + * `GCP_PROJECT_NUMBER=1234567890` +3. Open the app-level `build.gradle.kts` file and update the `applicationId` to + match the package name of the app entry you created in the Play Console. +4. Sync your project with Gradle files. +5. Select the `physicalRelease` variant from the Build Variants tool window, + opened via the Tool Window bar on the far left of the Android Studio + interface +6. [Generate a signed Android App Bundle (AAB)](https://developer.android.com/studio/publish/app-signing) + using Android Studio. +7. In the Google Play Console, + [set up an internal testing track](https://support.google.com/googleplay/android-developer/answer/9845334) + and upload your signed AAB as a new release. +8. Once the release is processed, use the internal testing link provided in the + Play Console to install the app onto your physical test device. +9. To allow the app on your device to communicate with your local machine's + backend server, connect the device via USB and set up ADB reverse port + forwarding in your terminal (replace `` with your Node server + port, e.g., 3000, and `` with the port number that the app + tries to access, e.g. 3000): `adb reverse tcp: + tcp:` + * Note: once the device is disconnected, you will need to run this command + again the next time you need to test this flow + +# Banking Micro App: Client + +[This directory](android-client/feature/bank) contains the Android client +implementation for the Bank micro-app. It focuses on the practical mechanics of +integrating the Play Integrity API Standard Request flow, including token +preparation, payload hashing, and UI-level error handling (Remediation Dialogs). + +### 1\. Token preparation (warm-up) + +To ensure the high-value action (tapping "Transfer") executes with minimal +latency, the client asynchronously calls +`StandardIntegrityManager.prepareIntegrityToken()` method to pre-warm the token +provider when the user navigates to the Transaction portal. + +* Implementation: This logic is managed within the + [BankViewModel.kt](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/ui/BankViewModel.kt) + `init` block, which delegates to the + [IntegrityRepositoryImpl.kt](android-client/core/integrity/src/main/java/com/android/security/samples/playintegrityapi/core/integrity/IntegrityRepository.kt)'s + `warmUp()` method. + +### 2\. Request hash generation + +When the user initiates the transfer, the client serializes the transaction +details into a JSON string and computes its SHA-256 hash using utilities in the +`:core:common` module. + +* Implementation: See the + [SubmitSecureTransferUseCase.kt](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/domain/SubmitSecureTransferUseCase.kt). + The resulting Base64-encoded SHA-256 string is passed directly as the + `requestHash` parameter into + `StandardIntegrityTokenProvider.requestIntegrityToken()`. + +### 3\. Network execution + +Once the Play Integrity token is generated, it is passed down to the +`:core:network` layer. + +* **Implementation:** The + [SubmitSecureTransferUseCase](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/domain/SubmitSecureTransferUseCase.kt) + invokes the + [BankRepository](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/data/repository/BankRepository.kt), + placing the raw JSON transaction in the HTTP body and injecting the token + into the `x-play-integrity-token` HTTP header for submission to the server. + +### 4\. Handling remediation + +A core feature of this client implementation is gracefully handling server-side +integrity rejections. + +![Triggering the GET\_INTEGRITY Remediation Dialog](media/bank_app_remediation.gif) + +***Figure 1\.** Triggering the GET\_INTEGRITY Remediation Dialog* + +If the backend decides the device or app does not meet the required security +policy, it returns a 403 Forbidden with a structured JSON payload. + +**Example Server Response:** + +```json +{ + "status": "ERROR", + "error_code": "INTEGRITY_REJECTED", + "message": "Device does not meet the required security standards.", + "remediation_code": 4 +} +``` + +**Client response flow:** + +1. **Parsing:** + [SubmitSecureTransferUseCase](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/domain/SubmitSecureTransferUseCase.kt) + parses the 403 error and extracts the + [remediationCode](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/domain/SubmitSecureTransferUseCase.kt). +2. **UI State Update:** The + [BankViewModel](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/ui/BankViewModel.kt) + updates the + [TransferUiState](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/ui/BankViewModel.kt) + to + [TransferUiState.Error.Integrity.Server](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/ui/BankViewModel.kt), + making the remediation type code available to the UI. +3. **UI Prompt:** + [BankRoute](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/ui/BankScreen.kt) + composable observes this state and presents an + [AlertDialog](https://developer.android.com/develop/ui/compose/components/dialog) + to the user. +4. **Triggering the Dialog:** If the user chooses to resolve it, + [BankViewModel.triggerRemediationDialog()](android-client/feature/bank/src/main/java/com/android/security/samples/playintegrityapi/feature/bank/ui/BankViewModel.kt) + invokes the `standardIntegrityManager.showDialog()` via the + [IntegrityRepository](android-client/core/integrity/src/main/java/com/android/security/samples/playintegrityapi/core/integrity/IntegrityRepository.kt) + to display the + [GET\_INTEGRITY](https://developer.android.com/google/play/integrity/remediation#get-integrity-dialog) + dialog. +5. **Resolution:** The client handles the result. If successful, the user can + re-attempt the transfer. + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +# Banking Micro App: Server + +[This directory](node-server/src/features/bank) contains the backend logic for +the Bank micro-app. It demonstrates how to securely parse HTTP requests, +cryptographically validate Play Integrity tokens, and enforce business rules +using a dedicated Policy file. + +## Architecture Overview + +The feature is split into two layers to maintain clean architecture: the +Controller +([bank.controller.js](node-server/src/features/bank/bank.controller.js)) and the +Policy ([bank.policy.js](node-server/src/features/bank/bank.policy.js)). + +### 1\. The Controller (bank.controller.js) + +The Controller handles the cryptographic validation phase of the request. The +token decoding is modularized into middleware. Its execution flow is as follows: + +1. **Extract & Decode Middleware:** The + [extractIntegrityToken](node-server/src/middleware/integrity.middleware.js) + middleware retrieves the `x-play-integrity-token` from the HTTP headers and + delegates decoding to the global + [integrity.service.js](node-server/src/services/integrity.service.js). +2. **Hash:** The controller computes a SHA-256 hash of the incoming JSON body + ([serverRequestHash](node-server/src/features/bank/bank.controller.js)). +3. **Binding (Tampering Protection):** The controller compares the + `serverRequestHash` against the `requestHash` returned inside the decrypted + token + ([tokenPayload.requestDetails.requestHash](node-server/src/features/bank/bank.controller.js)). + * Rejection: If they do not match, it means the payload was altered in + transit (Payload Hijacking / MitM). The controller instantly throws a + `403 Forbidden` with `error_code: "REQUEST_TAMPERED"`. + +### 2\. The Policy (bank.policy.js) + +Once the token is proven mathematically valid and bound to the correct payload, +the Controller delegates business-logic enforcement to the Policy layer by +calling +[bankPolicy.evaluateTransferPolicy(tokenPayload)](node-server/src/features/bank/bank.controller.js). + +The [BankPolicy](node-server/src/features/bank/bank.policy.js) class isolates +the specific rules required for the financial transaction. It inspects the JSON +verdicts and returns a boolean. For a transfer to succeed, the token must +satisfy all of the following: + +* **deviceRecognitionVerdict:** Must meet at least `MEETS_DEVICE_INTEGRITY` +* **appRecognitionVerdict:** Must equal `PLAY_RECOGNIZED`. +* **appLicensingVerdict:** Must equal `LICENSED`. +* **requestPackageName:** Must match the expected package name on the server. + +### 3\. Structured error formatting + +If `evaluateTransferPolicy` returns false, the Controller translates the failure +into an actionable HTTP response for the Android client. + +Rather than returning a generic 500 Internal Server Error, the API is designed +to return a 403 Forbidden containing the exact parameters the Android client +needs to trigger an in-app Remediation Dialog: + +```json +{ + "status": "ERROR", + "error_code": "INTEGRITY_REJECTED", + "message": "Device does not meet the required security standards.", + "remediation_code": 4 +} +``` + +This payload ensures the client knows *why* the request failed and *how* to +utilize Play Integrity API to fix it. + +-------------------------------------------------------------------------------- + +# Streaming Micro-App: Client + +[This directory](android-client/feature/streaming) contains the Android client +implementation for the Streaming micro-app. It focuses on configuring Android’s +[ExoPlayer](https://developer.android.com/media/media3/exoplayer) to utilize +Play Integrity tokens when setting up a +[DASH playback](https://developer.android.com/media/media3/exoplayer/dash). + +#### 1\. Token Preparation + +Media playback requires near-instantaneous network requests to prevent +buffering. To achieve this, the client pre-warms the token provider via the +`IntegrityRepository.warmUp()` method during application’s `onCreate()` method +so that subsequent integrity token requests before video playback only incurs a +latency of a few hundred milliseconds. + +In addition, if the integrity token generation fails, the client catches the +exception and intentionally proceeds with a null token rather than hard-failing. +This allows the backend to gracefully fall back to the lowest Restricted tier +(144p) instead of blocking the user. + +#### 2\. Request Hash Generation (Content Binding) + +Unlike the Bank micro-app where the payload is a complex transaction, the +streaming payload is simply the requested video context. The client manually +constructs a tight JSON string: + +```json +{ + "action": "fetch_manifest", + "contentId": "sample_video_01" +} +``` + +It then generates a SHA-256 hash of this string to produce the `requestHash` +which is passed to the Play Integrity API when requesting a token. + +In a production environment, you should strengthen this binding further. Instead +of just hashing the action and content ID, consider including a non sensitive +user-specific identifier within the hashed data. This could be: + +* A server-side session token +* The user's unique ID +* A nonce tied to the user's current session. + +By including a session-specific element, the JSON to be hashed might look like: + +```json +{ + "action": "fetch_manifest", + "contentId": "sample_video_01", + "sessionId": "user_session_abc123" +} +``` + +Hashing this entire string ensures the integrity token is bound not only to the +content but also to that specific user session, making it more difficult for an +attacker to replay a token across different users or sessions. The backend must +then be able to reconstruct and verify this same hash based on the authenticated +user's session and the requested content. + +#### 3\. ExoPlayer Network Injection + +The client does not manually download the XML manifest. Instead, it natively +instructs [ExoPlayer](https://developer.android.com/media/media3/exoplayer) to +append the integrity token to its outbound HTTP headers. + +The preparePlayerMediaSource() method instantiates a +[DefaultHttpDataSource.Factory()](https://developer.android.com/reference/androidx/media3/datasource/DefaultHttpDataSource.Factory). +We use +[setDefaultRequestProperties()](https://developer.android.com/reference/androidx/media3/datasource/DefaultHttpDataSource.Factory#setDefaultRequestProperties\(java.util.Map%3Cjava.lang.String,java.lang.String%3E\)) +to inject the `X-Play-Integrity-Token header`. This factory is passed into the +[DashMediaSource](https://developer.android.com/reference/androidx/media3/exoplayer/dash/DashMediaSource), +ensuring the token is present when ExoPlayer requests the .mpd file over the +network. + +#### 4\. Handling Dynamic Tiers & UI State + +The Android client is completely agnostic to the quality tier it receives. +ExoPlayer automatically parses the dynamically filtered DASH manifest returned +by the Node.js server. + +The `StreamingViewModel` attaches a +[Player.Listener](https://developer.android.com/reference/androidx/media3/common/Player.Listener) +to the player. When the manifest loads, `onTracksChanged()` scans the available +video tracks to find the maximum videoHeight the server authorized. It then maps +this height (e.g., \>= 1080, \>= 720\) to the +`StreamingUiState.activeTierIndex`, which instantly updates the Compose UI to +highlight the correct "Premium", "Standard", or "Restricted" card on the screen. + +# Streaming Micro-App: Server + +[This directory](node-server/src/features/streaming) contains the backend logic +for the Streaming micro-app. It demonstrates how to parse standard integrity +tokens, enforce tiered access policies, and dynamically modify DASH XML +manifests. + +The backend is structured around three primary components: the Controller, the +Policy, and the Manifest Service. + +1. The Controller + ([streaming.controller.js](node-server/src/features/streaming/streaming.controller.js)): + The controller handles cryptographic validation and routing. + * **Token Decoding & Replay Protection:** The token is extracted and + decoded via the `integrity.middleware`. Because we use Standard + requests, Google's server + [automatically detects](https://developer.android.com/google/play/integrity/standard#replay-protection) + and rejects replayed tokens. + * **Content Binding Check:** The `#isContentBindingValid` method creates a + mirror payload (`{ action: 'fetch_manifest', contentId: 'contentId'})`, + computes its hash, and compares it against the `requestHash` inside the + decoded token. If they mismatch, the controller falls back to the + Restricted tier. +2. The Policy + ([streaming.policy.js](node-server/src/features/streaming/streaming.policy.js)): + This file evaluates the trust level of the device to determine the maximum + allowed video resolution. The `evaluateStreamQuality` function translates + Play Integrity labels into business rules: + * 1080p (Premium): Requires `MEETS_STRONG_INTEGRITY` and an Android SDK + version \>= 33\. + * 720p (High): Requires `MEETS_DEVICE_INTEGRITY` (SDK \>= 33\) or + `MEETS_STRONG_INTEGRITY` (SDK \< 33). + * 480p (Standard): Requires `MEETS_DEVICE_INTEGRITY` (SDK \< 33). + * 240p (Basic): Requires `MEETS_BASIC_INTEGRITY`. + * 144p (Restricted): The default fallback. +3. The Manifest Service + ([manifest.service.js](node-server/src/features/streaming/manifest.service.js)): + Once the policy determines the maximum allowed resolution, the controller + delegates to the `ManifestService`. The service parses the root XML manifest + using the xml2js library. The `#filterVideoRepresentations` method locates + the `AdaptationSet` for video and strips out any `` nodes + that exceed the permitted height limit dictated by the policy. + +—-------------------------------------------------------------------------------------------------------------------------------------------------------- + +# Game Micro-App: Client + +[This directory](android-client/feature/game) contains the Android client +implementation for the Game micro-app. It focuses on orchestrating a real-time +game session, handling continuous background Play Integrity token generation on +dynamic intervals, and utilizing several Play remediation dialogs. + +#### 1\. Token preparation and session initialisation + +When the user navigates to the Rhythm Pulse micro-app, the client automatically +pre-warms the token provider using `IntegrityRepository.warmUp()`. \ +When the user taps Start Secure Session, the `InitiateGameUseCase` fetches an +initial Play Integrity token and calls `POST /api/v1/game/initiate`. The server +returns a unique `sessionId`, the game’s `targetTime`, and an array of +randomised check-in intervals (e.g. `[2.5, 5.12, 8.3]`). + +#### 2\. TOCTOU defence + +Once gameplay begins, the client must periodically prove its environment remains +secure. + +* An asynchronous coroutine monitors the intervals returned by the server. +* When an interval is reached, the client applies a randomised padding offset + (jitter) and requests a standard integrity token in the background via the + `GenerateIntervalTokenUseCase`. +* Content Binding the Interval: To cryptographically bind the background token + to this exact time window, the client hashes a dynamic challenge string. +* The resulting SHA-256 hash is passed as the requestHash parameter, and the + token is stored in memory alongside its interval time. + +#### 3\. Session stop and final submission + +When the user taps **Stop**, the `SubmitGameScoreUseCase` prepares a final +payload containing the local `actualTime` elapsed and the array of collected +background `intervalTokens`. A final closing Play Integrity token is generated, +bound to the hash of this entire final JSON payload, and submitted to `POST +/api/v1/game/stop`. + +#### 4\. Handling environment remediation + +![Game sample showcasing the effect of an unknown app capturing +the screen during an active game session and Play remediation](media/game_app_remediation.gif) + +***Figure 2\.** Game sample showcasing the effect of an unknown app capturing +the screen during an active game session and Play remediation* + +If the server rejects a session due to an environmental policy failure, the +`GameViewModel.triggerRemediationDialog()`leverages Play’s user remediation +features to prompt the user to either close apps that maybe recording their +screen or controlling their device via the `CLOSE_ALL_ACCESS_RISK` dialog, or +uninstall malicious apps on their device by using the `GET_STRONG_INTEGRITY` +dialog. + +# Game Micro-App: Server + +[This directory](node-server/src/features/game) contains the backend +implementation for the Game sample. It showcases a stateful, secure verification +pattern designed to defeat TOCTOU (Time-of-Check to Time-of-Use) cheats, enforce +strict environment policies, and securely evaluate background Play Integrity API +attestations. + +### Architecture overview + +The feature is divided into the Controller +([game.controller.js](node-server/src/features/game/game.controller.js)) and the +Policy ([game.policy.js](node-server/src/features/game/game.policy.js)). The +entire architecture relies on Play Integrity API tokens to validate state at +multiple points in time in a session. + +1. The `GameController` manages active sessions, orchestrates integrity token + verification to decide if a user’s final score would be added to a + hypothetical leaderboard. +2. The `GamePolicy` class acts as the core rules engine, evaluating the + decrypted Play Integrity JSON payload, focusing heavily on interpreting the + `environmentDetails` returned by the Integrity API: + * Play Protect: Evaluates `playProtectVerdict` to ensure it equals + `NO_ISSUES`. If threats are found, this signals a compromised + environment harbouring potentially malicious software. + * App access risk evaluation: It parses the `appsDetected` array from the + `appAccessRiskVerdict`: + * If the array includes `UNKNOWN_CAPTURING`, `screenCaptureSafe` is + flagged as false. + * If the array includes `UNKNOWN_OVERLAYS` or `UNKNOWN_CONTROLLING`, + `accessibilitySafe` is flagged as false, preventing automated + clickers or malicious overlays. + * Strict fallback: If `environmentDetails` or the `appAccessRiskVerdict` + are missing entirely from the integrity token payload, the policy + securely defaults `screenCaptureSafe` and `accessibilitySafe` to false. + This ensures the system does not blindly approve unevaluated + environments. + +### API Endpoints + +#### `POST /api/v1/game/initiate` + +Initialises a secure gameplay session, triggering an initial evaluation and +returning the randomised check-in intervals. + +**Request headers:** \ +`x-play-integrity-token`: `` + +**Success response (`200 OK`):** + +```json +{ + "status": "SUCCESS", + "sessionId": "b6a0ff4d-0453-481b-8512-1df69614db5a", + "targetTime": 15.34, + "intervals": [2.45, 6.12, 10.89], + "checklist": { + "isSecure": true, + "screenCaptureSafe": true, + "accessibilitySafe": true, + "playProtectSafe": true + } +} +``` + +#### `POST /api/v1/game/status` + +Queries the real-time security state of the device environment. Typically +triggered on demand or when the mobile app is resumed from a background state. + +**Request headers:** \ +`x-play-integrity-token`: `` + +**Success response (`200 OK`):** + +```json +{ + "status": "SUCCESS", + "checklist": { + "isSecure": true, + "screenCaptureSafe": true, + "accessibilitySafe": true, + "playProtectSafe": true + } +} +``` + +#### `POST /api/v1/game/stop` + +Submits the final score payload along with all background Play Integrity tokens +for strict verification. + +**Request headers:** \ +`x-play-integrity-token`: `` + +**Request payload:** + +```json +{ + "sessionId": "b6a0ff4d-0453-481b-8512-1df69614db5a", + "clientStartTime": 1727247472000, + "actualTime": 16.54, + "intervalTokens": [ + { "interval": 2.45, "token": "" }, + { "interval": 6.12, "token": "" }, + { "interval": 10.89, "token": "" } + ] +} +``` + +**Success response (`200 OK`):** + +```json +{ + "status": "SUCCESS", + "message": "Score verified." +} +``` + +**Tampered/Compromised response (`403 Forbidden`):** + +```json +{ + "status": "ERROR", + "error_code": "ENVIRONMENT_COMPROMISED", + "message": "Cheat toggling detected: Environment compromised at interval 6.12s." +} +``` + +-------------------------------------------------------------------------------- + +# Testing Play Console Integrity Responses + +This section guides you through using the Play Integrity API test responses +feature in the +[Google Play Console](https://developer.android.com/distribute/console) to +dynamically alter the streaming quality in the sample app. This assumes you have +already completed the full end-to-end setup as described in the root project +guide (i.e. app created in Play Console, Play Integrity API enabled, Google +Cloud project linked, Node.js server running, Android app buildable). + +#### Prerequisites + +* A Google Play Developer account. +* Your app is set up in the Play Console. +* Play Integrity API is enabled for your app and linked to your Google Cloud + project. +* The sample Node.js backend server is running. +* The Android client app is installed and runnable on a device or emulator, + signed in with a Google account. + +#### Steps to Test Different Integrity Verdicts + +1. **Navigate to Play Integrity API Settings:** + * Open the Google Play Console. + * Select your application. + * In the Play Console left navigation menu, select **Protected with + Play**. + * On the **Protected with Play** page, locate the **Play Integrity API** + row and click the **Manage** button. + +![Navigating to Play Integrity API settings on Play Console](media/integrity_api_settings_navigation.png) + +2. **Configure Test Responses:** + * Scroll down to the Testing section. + * Click on Create new test. + * Give your test a descriptive name (e.g., "Device Unrecognized Test"). + * Under Email lists, select or create an email list containing the Google + account(s) used on your test device(s). + * Modify the Integrity verdicts to simulate different scenarios. For + example: + * Premium Quality (Fully Trusted): + * `appRecognitionVerdict: PLAY_RECOGNIZED` + * `deviceRecognitionVerdict: [MEETS_DEVICE_INTEGRITY, + MEETS_STRONG_INTEGRITY]` + * `appLicensingVerdict: LICENSED` + * Basic Quality (Basic Integrity): + * `appRecognitionVerdict: PLAY_RECOGNIZED` + * `deviceRecognitionVerdict: [MEETS_BASIC_INTEGRITY]` + * `appLicensingVerdict: LICENSED` + * Restricted Quality (No Device Integrity): + * `appRecognitionVerdict: UNEVALUATED` + * `deviceRecognitionVerdict: []` (Empty) + *`appLicensingVerdict: UNEVALUATED` + +![UI to configure a test response](media/integrity_api_test_response_configuration.png) + +3. **Save the test configurations:** + * Click Create test. You might need to click Save changes at the bottom of + the page too. Propagation time varies depending on multiple factors, but + changes should be reflected in about 1-2 hours at the longest. +4. **Observe in the Android App:** + * Open the Streaming micro-app on your test device (ensuring it's logged + in to one of the accounts from the email list in the test + configuration). + * The app might show a quality level based on a previous integrity check. + * Click the "Refresh Integrity Check" button within the app. This action + forces the app to request a new Play Integrity token and DASH manifest. + * Play Integrity API will return a token with the verdicts you configured + in the Play Console test. + * The Node.js server will decode this test token and return a DASH + manifest filtered according to the tier mapped to the received verdicts. + * Observe the UI: The highlighted tier card ("Premium", "Standard", or + "Restricted") should update, and the video playback quality will adjust + after ExoPlayer reloads the manifest. + +#### Example Scenarios to Try: + +* **Simulate a Rooted/Compromised Device:** Set `deviceRecognitionVerdict` to + be empty. The stream should degrade to the "Restricted" tier. +* **Simulate an Unlicensed User:** Set `appLicensingVerdict` to `UNLICENSED`. + The stream should also degrade to the "Restricted" tier. +* **Simulate a Fully Trusted Device & Licensed User:** Ensure verdicts are + `MEETS_STRONG_INTEGRITY`, `PLAY_RECOGNIZED`, and `LICENSED`. The stream + should allow "Premium" quality. + +By changing the test responses in the Play Console and using the "Refresh +Integrity Check" button, you can effectively test how the end-to-end integration +handles various Play Integrity API outcomes and confirm that the stream quality +adjusts dynamically as expected. + diff --git a/PlayIntegrityAPI/android-client/.gitignore b/PlayIntegrityAPI/android-client/.gitignore new file mode 100644 index 00000000..752c646e --- /dev/null +++ b/PlayIntegrityAPI/android-client/.gitignore @@ -0,0 +1,18 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.kotlin +.DS_Store +/app/physical +/build +/captures +.externalNativeBuild +.cxx +local.properties +upload-keystore.jks diff --git a/PlayIntegrityAPI/android-client/app/.gitignore b/PlayIntegrityAPI/android-client/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/build.gradle.kts b/PlayIntegrityAPI/android-client/app/build.gradle.kts new file mode 100644 index 00000000..597ba7d2 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.ksp) + alias(libs.plugins.compose.screenshot) +} + +android { + namespace = "com.android.security.samples.playintegrityapi" + compileSdk { + version = release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.pia.canonical.sample" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 7 + versionName = "1.0.7" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + + flavorDimensions += "environment" + productFlavors { + create("emulator") { + dimension = "environment" + } + create("physical") { + dimension = "environment" + } + } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:network")) + implementation(project(":core:integrity")) + implementation(project(":feature:bank")) + implementation(project(":feature:streaming")) + implementation(project(":feature:game")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.espresso.core) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) +} \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/proguard-rules.pro b/PlayIntegrityAPI/android-client/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/androidTest/java/com/android/security/samples/playintegrityapi/HomeScreenTest.kt b/PlayIntegrityAPI/android-client/app/src/androidTest/java/com/android/security/samples/playintegrityapi/HomeScreenTest.kt new file mode 100644 index 00000000..10e94586 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/androidTest/java/com/android/security/samples/playintegrityapi/HomeScreenTest.kt @@ -0,0 +1,102 @@ +package com.android.security.samples.playintegrityapi + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.security.samples.playintegrityapi.ui.HomeScreen +import com.android.security.samples.playintegrityapi.core.ui.theme.PiaSampleTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private fun getString(id: Int) = context.getString(id) + + @Test + fun homeScreen_displaysAllExpectedElements() { + composeTestRule.setContent { + PiaSampleTheme { + HomeScreen( + onNavigateToBank = {}, + onNavigateToStreaming = {}, + onNavigateToGame = {} + ) + } + } + + // Verify Top Bar + composeTestRule.onNodeWithText(getString(R.string.home_top_bar_title)).assertIsDisplayed() + + // Verify Cards + composeTestRule.onNodeWithText(getString(R.string.use_case_bank_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.use_case_streaming_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.use_case_game_title)).assertIsDisplayed() + + // Verify Status Pill + composeTestRule.onNodeWithText(getString(R.string.home_status_integrity_secure)).assertIsDisplayed() + } + + @Test + fun homeScreen_clickingBankCard_triggersNavigation() { + var bankNavigated = false + + composeTestRule.setContent { + PiaSampleTheme { + HomeScreen( + onNavigateToBank = { bankNavigated = true }, + onNavigateToStreaming = {}, + onNavigateToGame = {} + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.use_case_bank_title)).performClick() + assertTrue(bankNavigated) + } + + @Test + fun homeScreen_clickingStreamingCard_triggersNavigation() { + var streamingNavigated = false + + composeTestRule.setContent { + PiaSampleTheme { + HomeScreen( + onNavigateToBank = {}, + onNavigateToStreaming = { streamingNavigated = true }, + onNavigateToGame = {} + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.use_case_streaming_title)).performClick() + assertTrue(streamingNavigated) + } + + @Test + fun homeScreen_clickingGameCard_triggersNavigation() { + var gameNavigated = false + + composeTestRule.setContent { + PiaSampleTheme { + HomeScreen( + onNavigateToBank = {}, + onNavigateToStreaming = {}, + onNavigateToGame = { gameNavigated = true } + ) + } + } + + composeTestRule.onNodeWithText(getString(R.string.use_case_game_title)).performClick() + assertTrue(gameNavigated) + } +} \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/AndroidManifest.xml b/PlayIntegrityAPI/android-client/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..643f31b7 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/PlayIntegrityApplication.kt b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/PlayIntegrityApplication.kt new file mode 100644 index 00000000..26b671dd --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/PlayIntegrityApplication.kt @@ -0,0 +1,7 @@ +package com.android.security.samples.playintegrityapi + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PlayIntegrityApplication : Application() \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/navigation/HomeNavigation.kt b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/navigation/HomeNavigation.kt new file mode 100644 index 00000000..db77b1d4 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/navigation/HomeNavigation.kt @@ -0,0 +1,36 @@ +package com.android.security.samples.playintegrityapi.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.android.security.samples.playintegrityapi.ui.HomeRoute + +/** + * Route for the Home screen. + */ +const val HOME_ROUTE = "home_route" + +/** + * Navigates the user to the Home screen. + */ +fun NavController.navigateToHome(navOptions: NavOptions? = null) { + this.navigate(HOME_ROUTE, navOptions) +} + +/** + * Registers the Home screen into the main application navigation graph. + */ +fun NavGraphBuilder.homeScreen( + onNavigateToBank: () -> Unit, + onNavigateToStreaming: () -> Unit, + onNavigateToGame: () -> Unit +) { + composable(route = HOME_ROUTE) { + HomeRoute( + onNavigateToBank = onNavigateToBank, + onNavigateToStreaming = onNavigateToStreaming, + onNavigateToGame = onNavigateToGame + ) + } +} \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/ui/Home.kt b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/ui/Home.kt new file mode 100644 index 00000000..61e4159c --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/ui/Home.kt @@ -0,0 +1,269 @@ +package com.android.security.samples.playintegrityapi.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.outlined.AccountBalance +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.PlayCircleOutline +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.security.samples.playintegrityapi.R +import com.android.security.samples.playintegrityapi.core.ui.theme.PiaSampleTheme +import com.android.security.samples.playintegrityapi.core.ui.theme.Slate400 + +@Composable +fun HomeRoute( + onNavigateToBank: () -> Unit, + onNavigateToStreaming: () -> Unit, + onNavigateToGame: () -> Unit, + modifier: Modifier = Modifier +) { + HomeScreen( + onNavigateToBank = onNavigateToBank, + onNavigateToStreaming = onNavigateToStreaming, + onNavigateToGame = onNavigateToGame, + modifier = modifier + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onNavigateToBank: () -> Unit, + onNavigateToStreaming: () -> Unit, + onNavigateToGame: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + Column { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.home_top_bar_title), + style = MaterialTheme.typography.labelMedium, + color = Slate400 + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(0.4f)) + } + }, + containerColor = MaterialTheme.colorScheme.background, + modifier = modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = stringResource(id = R.string.home_header_select_use_case), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Bank Micro-App Card + UseCaseCard( + icon = Icons.Outlined.AccountBalance, + title = stringResource(id = R.string.use_case_bank_title), + description = stringResource(id = R.string.use_case_bank_desc), + onClick = onNavigateToBank + ) + + // Streaming Micro-App Card + UseCaseCard( + icon = Icons.Outlined.PlayCircleOutline, + title = stringResource(id = R.string.use_case_streaming_title), + description = stringResource(id = R.string.use_case_streaming_desc), + onClick = onNavigateToStreaming + ) + + // Game Micro-App Card + UseCaseCard( + icon = Icons.Outlined.Fingerprint, + title = stringResource(id = R.string.use_case_game_title), + description = stringResource(id = R.string.use_case_game_desc), + onClick = onNavigateToGame + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + StatusPill( + modifier = Modifier + .padding(vertical = 32.dp) + // Temporarily disable status pill until it's able to accurately reflect + // the state of the connection between the client and server and the device + // integrity + .alpha(0f) + ) + } + } +} + +@Composable +fun UseCaseCard( + icon: ImageVector, + title: String, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + OutlinedCard( + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + modifier = modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .alpha(0.6f) + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Light), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = stringResource(id = R.string.use_case_navigate_content_desc), + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(start = 16.dp) + ) + } + } +} + +@Composable +private fun StatusPill(modifier: Modifier = Modifier) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + ) + Text( + text = stringResource(id = R.string.home_status_integrity_secure), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +internal fun HomeScreenPreview() { + PiaSampleTheme(dynamicColor = false) { + HomeScreen( + onNavigateToBank = {}, + onNavigateToStreaming = {}, + onNavigateToGame = {} + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +internal fun UseCaseCardPreview() { + PiaSampleTheme(dynamicColor = false) { + Box(modifier = Modifier.padding(16.dp)) { + UseCaseCard( + icon = Icons.Outlined.AccountBalance, + title = stringResource(id = R.string.use_case_bank_title), + description = stringResource(id = R.string.use_case_bank_desc), + onClick = {} + ) + } + } +} \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/ui/MainActivity.kt b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/ui/MainActivity.kt new file mode 100644 index 00000000..d8fbffd7 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/java/com/android/security/samples/playintegrityapi/ui/MainActivity.kt @@ -0,0 +1,95 @@ +package com.android.security.samples.playintegrityapi.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import com.android.security.samples.playintegrityapi.feature.bank.ui.navigation.bankScreen +import com.android.security.samples.playintegrityapi.feature.bank.ui.navigation.navigateToBank +import com.android.security.samples.playintegrityapi.feature.streaming.ui.navigation.navigateToStreaming +import com.android.security.samples.playintegrityapi.feature.streaming.ui.navigation.streamingScreen +import com.android.security.samples.playintegrityapi.navigation.HOME_ROUTE +import com.android.security.samples.playintegrityapi.navigation.homeScreen +import com.android.security.samples.playintegrityapi.core.ui.theme.PiaSampleTheme +import com.android.security.samples.playintegrityapi.feature.game.ui.navigation.gameScreen +import com.android.security.samples.playintegrityapi.feature.game.ui.navigation.navigateToGame +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + PiaSampleTheme(dynamicColor = false) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PlayIntegrityApp() + } + } + } + } +} + +/** + * Top-level composable that manages application state and navigation. + */ +@Composable +fun PlayIntegrityApp() { + val navController = rememberNavController() + val animationDuration = 300 + + NavHost( + navController = navController, + startDestination = HOME_ROUTE, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(animationDuration) + ) + fadeIn(animationSpec = tween(animationDuration)) + }, + exitTransition = { + fadeOut(animationSpec = tween(animationDuration)) + }, + popEnterTransition = { + fadeIn(animationSpec = tween(animationDuration)) + }, + popExitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(animationDuration) + ) + fadeOut(animationSpec = tween(animationDuration)) + } + ) { + homeScreen( + onNavigateToBank = { navController.navigateToBank() }, + onNavigateToStreaming = { navController.navigateToStreaming() }, + onNavigateToGame = { navController.navigateToGame() } + ) + + bankScreen( + onBackClick = { navController.popBackStack() } + ) + + streamingScreen( + onBackClick = { navController.popBackStack() } + ) + + gameScreen( + onBackClick = { navController.popBackStack() } + ) + } +} \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/drawable/ic_launcher_background.xml b/PlayIntegrityAPI/android-client/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/drawable/ic_launcher_foreground.xml b/PlayIntegrityAPI/android-client/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/PlayIntegrityAPI/android-client/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/values/colors.xml b/PlayIntegrityAPI/android-client/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/values/strings.xml b/PlayIntegrityAPI/android-client/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..474ab9f1 --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + PlayIntegrityAPI + + PLAY INTEGRITY API SAMPLE + Select a Play Integrity API use case + SYSTEM INTEGRITY STATUS: SECURE + + + Protect a high value action + Bank transfer example demonstrating secure data transmission, content binding and user remediation + + Protect a server-side resource + Media stream example demonstrating tiered server responses based on trust levels + + Protect a session environment + Gaming example demonstrating checking for threats like screen capturing, a11y misuse, and known malware + + + Navigate + \ No newline at end of file diff --git a/PlayIntegrityAPI/android-client/app/src/main/res/values/themes.xml b/PlayIntegrityAPI/android-client/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..6d230a9f --- /dev/null +++ b/PlayIntegrityAPI/android-client/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +