# ClearTape AR Measure — build spec

A fair-priced AR measuring tape that lets users measure before paying, uses a one-time unlock instead of weekly billing, and shows confidence warnings when ARCore conditions are poor.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** AR Meter: Tape Measure Camera
- **Package id:** `com.ignatiusDevStudio.ARMeter`
- **Google Play:** https://play.google.com/store/apps/details?id=com.ignatiusDevStudio.ARMeter
- **appy.fyi report:** https://appy.fyi/report/com.ignatiusDevStudio.ARMeter
- **Category:** Tools

## Overview

- **Working name:** ClearTape AR Measure (trademark cleared: no)
- **Package id:** `fyi.appy.cleartapearmeasure`
- **Min / target SDK:** 24 / 35
- **Backend:** none
- **Estimated build time:** 5 weeks
- **Pricing:** one-time purchase, $4.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `CAMERA`

## Non-goals (out of scope for v1)

- No weekly, monthly, or trial subscription flow in v1.
- No ads, interstitials, or full-screen monetization before the first measurement.
- No room scanning, floor-plan generation, or multi-room project management.
- No cloud accounts, sync, backup, or server-side measurement history.
- No claim that measurements are legally certified, construction-grade, or suitable as legal evidence.
- No AI object recognition or AI-assisted measurement correction.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| ARCore session, camera tracking, plane detection, hit testing, anchors, and light estimates | `com.google.ar:core:1.46.0` |
| Play Billing one-time in-app product purchase flow | `com.android.billingclient:billing-ktx:7.1.1` |
| Compose host Activity integration | `androidx.activity:activity-compose:1.9.3` |
| Compose Material 3 UI components | `androidx.compose.material3:material3:1.3.0` |
| Compose UI primitives used by the measurement overlay and editor | `androidx.compose.ui:ui:1.7.4` |
| Compose tooling preview support for development builds | `androidx.compose.ui:ui-tooling-preview:1.7.4` |
| Compose Navigation graph for Measure, Screenshot Editor, Settings, Unlock, and Billing Help screens | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel integration with Compose screen state | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware collection of StateFlow in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Room database runtime for local settings, purchase entitlement cache, and saved capture metadata | `androidx.room:room-runtime:2.6.1` |
| Room coroutine extensions for DAO calls | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor used through KSP | `androidx.room:room-compiler:2.6.1` |
| KSP Gradle plugin required by Room code generation | `com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.21-1.0.27` |
| AndroidX FileProvider and core Kotlin helpers for sharing screenshot files | `androidx.core:core-ktx:1.13.1` |

## Design system

- **Primary color:** `#1565C0`
- **Background color:** `#FAFAF7`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `ruler` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme background is #FAFAF7 with #1565C0 primary actions; dark theme background is #101418 with #8AB4F8 primary actions. Measurement distance labels use white text on a #000000 scrim at 72% opacity so they remain readable over the camera feed. Confidence states use green #2E7D32 for High, amber #F9A825 for Medium, and error #B3261E for Low.

## Screens

### Measure
- **Route:** `measure`
- **Purpose:** Main AR camera screen where the user detects a surface, places two measurement points, sees distance and confidence, and can capture the result.
- **Reached via:** app launch; tap back from Settings; tap back from Unlock; tap retake from Screenshot Editor
- **Key UI elements:** Camera permission request panel when CAMERA is not granted; ARCore availability/install-required panel with a button that starts ArCoreApk requestInstall; Full-screen GLSurfaceView camera preview rendered behind Compose overlay; Center reticle and tap instruction text; Surface detection status chip; Two point markers labeled A and B; Distance label in selected unit; Confidence indicator chip showing High, Medium, or Low with the reason; Reset button; Capture button; Settings button; Non-blocking Unlock button shown only when the one-time product is not owned
- **States:** checking_arcore, camera_permission_required, arcore_install_required, arcore_unavailable_error, detecting_surface, ready_to_place_first_point, one_point_placed, measurement_populated, low_confidence_measurement, capture_in_progress, capture_error

### Screenshot Editor
- **Route:** `screenshot/{captureId}`
- **Purpose:** Allows the user to add a short note to an AR measurement screenshot, save it locally, or share it through Android Sharesheet.
- **Reached via:** tap Capture on Measure after a successful screenshot bitmap is written to cache
- **Key UI elements:** Captured image preview; Read-only measured distance label; Single-line annotation text field capped at 80 characters; Save to Pictures button; Share button; Retake button; Error snackbar
- **States:** loading_capture, editing, saving, saved, sharing, capture_not_found_error, save_error, share_error

### Settings
- **Route:** `settings`
- **Purpose:** Lets the user choose units, view purchase status, open the one-time unlock screen, and open billing/refund help.
- **Reached via:** tap Settings button on Measure
- **Key UI elements:** Unit selector with Metric and Imperial radio options; Show center reticle switch; Purchase status row showing Free tier or Unlocked; One-time unlock row; Billing and refund help row; Privacy note stating measurements and screenshots stay on device unless shared
- **States:** loading, populated, saving, billing_status_refreshing, error

### Unlock
- **Route:** `unlock`
- **Purpose:** Presents the optional $4.99 one-time unlock clearly without blocking the first measuring session.
- **Reached via:** tap Unlock button on Measure; tap One-time unlock row on Settings; tap optional unlock prompt after saving or sharing a screenshot
- **Key UI elements:** Headline: One-time unlock; Price text: $4.99 once; Disclosure text: No weekly subscription. No free-trial charge. Measuring works before purchase.; Unlock with Google Play button; Restore purchase button; Continue measuring for free text button; Billing error message area
- **States:** loading_product, not_purchased, purchase_in_progress, purchased, billing_unavailable, purchase_cancelled, purchase_error

### Billing Help
- **Route:** `billing_help`
- **Purpose:** Explains the app's no-subscription billing model and gives a direct path to Google Play purchase history for refunds.
- **Reached via:** tap Billing and refund help row on Settings; tap billing help link on Unlock
- **Key UI elements:** Statement: This app has no subscription to cancel; Statement: The only paid item is a $4.99 one-time unlock; Open Google Play purchase history button; Open Google Play subscriptions button for reassurance; Back button
- **States:** static_content, external_intent_error

## Data model

### UserSettings (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 |
| unitSystem | `String` | either "metric" or "imperial"; default "metric" |
| showCenterReticle | `Boolean` | default true |
| lastUpdatedEpochMillis | `Long` | System.currentTimeMillis when settings are saved |

### PurchaseEntitlement (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| productId | `String` | primary key; use "clear_tape_unlock" |
| isUnlocked | `Boolean` | true only after Play Billing reports a PURCHASED and acknowledged one-time product |
| purchaseTokenHash | `String?` | nullable SHA-256 hash of purchase token; do not store raw token |
| lastVerifiedAtEpochMillis | `Long` | last time queryPurchasesAsync confirmed entitlement |

### MeasurementCapture (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| cacheFilePath | `String` | absolute path to PNG file in app cache directory |
| savedMediaUri | `String?` | nullable MediaStore URI after Save to Pictures succeeds |
| distanceMeters | `Double` | raw measured distance in meters |
| displayUnit | `String` | "cm", "m", "in", or "ft" as displayed when captured |
| displayValue | `String` | formatted value shown on the screenshot, for example "174.2 cm" |
| confidenceLevel | `String` | "high", "medium", or "low" |
| annotationText | `String` | empty string when no note is entered; max 80 characters |
| createdAtEpochMillis | `Long` | System.currentTimeMillis when capture is created |

## Features

### ARCore two-point measuring

Lets the user place two anchors on detected ARCore planes and displays the straight-line distance between them.

- **Answers complaint:** baseline parity

- **Screens:** Measure

- **Estimated hours:** 64

**Implementation notes:** Create an ARCore Session after CAMERA permission is granted and ArCoreApk availability is SUPPORTED_INSTALLED or installation succeeds. Configure Session with Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL and Config.LightEstimationMode.AMBIENT_INTENSITY. Render the camera feed in a GLSurfaceView using the ARCore background texture path from Session.setCameraTextureName. On each frame, call session.update(), render detected planes, and keep the latest Frame in view state. On user tap, call frame.hitTest(tapX, tapY), choose the first HitResult whose Trackable is a Plane with plane.isPoseInPolygon(hit.hitPose) and TrackingState.TRACKING, then create an Anchor from that hit. First valid tap stores anchorA; second valid tap stores anchorB. Distance is sqrt((ax-bx)^2 + (ay-by)^2 + (az-bz)^2) from each anchor.pose.translation components, stored in meters. If two anchors exist, draw markers A and B and a line between their projected screen positions; show the formatted distance. Reset detaches both anchors and returns to ready_to_place_first_point.

**Acceptance criteria:**
- With CAMERA permission granted and ARCore installed, the Measure screen reaches ready_to_place_first_point after a plane is tracked.
- The first valid plane tap creates exactly one anchor and changes the state to one_point_placed.
- The second valid plane tap creates a second anchor and changes the state to measurement_populated.
- For two test poses at (0, 0, 0) and (0, 0, 1), the distance formatter receives 1.0 meter.
- Tapping Reset detaches both anchors and removes the displayed distance.

### Measurement confidence indicator

Shows High, Medium, or Low confidence based on tracking state, surface tracking, and lighting so users are warned about ARCore limitations.

- **Answers complaint:** Poor accuracy Measurements reported wildly off (a 175cm person measured as 1110cm), undermining the core promise even for paying users.

- **Screens:** Measure, Screenshot Editor

- **Estimated hours:** 16

**Implementation notes:** Compute confidence every frame after at least one anchor is placed. Inputs are frame.camera.trackingState, each anchor.trackingState, the associated plane tracking state when available, and frame.lightEstimate.pixelIntensity. Return Low if camera is not TRACKING, either anchor is PAUSED or STOPPED, no plane has been tracking within the last 2 seconds, or pixelIntensity is below 0.35. Return Medium if pixelIntensity is between 0.35 and 0.60 inclusive, if either anchor's parent plane is no longer the current tracked plane but anchors still track, or if plane polygon extent changed by more than 20% after anchor placement. Return High only when camera and both anchors are TRACKING, the source plane is TRACKING, and pixelIntensity is greater than 0.60. Display the reason string with the chip: Low light, Tracking lost, Surface unstable, or Good tracking. Do not block measurement; warn that the user should remeasure when Low.

**Acceptance criteria:**
- When camera tracking state is PAUSED, the chip shows Low and reason Tracking lost.
- When pixelIntensity is 0.20 with tracking otherwise valid, the chip shows Low and reason Low light.
- When pixelIntensity is 0.50 with tracking valid, the chip shows Medium.
- When pixelIntensity is 0.80 and camera, plane, and anchors are TRACKING, the chip shows High.
- The captured screenshot metadata stores the confidence level visible at capture time.

### Real free measuring before payment

Allows the user to complete measurements without entering a card, seeing ads, or being forced through a paywall first.

- **Answers complaint:** Paywall before first use Full-screen ads or payment screens shown before any use of the core measuring function.

- **Screens:** Measure, Unlock

- **Estimated hours:** 12

**Implementation notes:** Route app launch directly to measure. Do not navigate to Unlock automatically from cold start, first permission grant, ARCore install completion, or first successful plane detection. The optional Unlock button is a small top-row text/button element and never a modal. Keep measuring, reset, unit selection, confidence display, and at least one screenshot capture available when PurchaseEntitlement.isUnlocked is false. Do not integrate any ad SDK. After a screenshot save/share action, an optional non-blocking prompt may link to Unlock, but it must include a visible Continue measuring for free action and dismiss without changing Measure state.

**Acceptance criteria:**
- On a fresh install with no purchase, app launch opens Measure rather than Unlock.
- Before any purchase, the user can place two points and see a distance.
- Before any purchase, no full-screen billing UI appears automatically during the first measurement flow.
- The app contains no ad SDK dependency and no ad view in the Measure screen hierarchy.
- The Unlock screen has a Continue measuring for free action that returns to Measure.

### Save, share, and annotate measurement screenshots

Captures the AR view with the measured distance and lets the user add a short note, save the image, or share it.

- **Answers complaint:** baseline parity

- **Screens:** Measure, Screenshot Editor

- **Estimated hours:** 40

**Implementation notes:** Enable Capture only when two anchors exist and distance is visible. Use PixelCopy.request on the GLSurfaceView surface to copy the AR camera frame into a Bitmap. Draw overlays onto a mutable Bitmap with android.graphics.Canvas: line between projected anchor points, A/B markers, displayValue, confidence chip, and annotation text if present. Write initial PNG to context.cacheDir/measurement_captures/capture_<timestamp>.png and insert a MeasurementCapture row. Navigate to screenshot/{captureId}. In Screenshot Editor, enforce annotationText.take(80). Save to Pictures by inserting into MediaStore.Images.Media with DISPLAY_NAME clear_tape_<id>.png, MIME_TYPE image/png, and RELATIVE_PATH Pictures/ClearTape on API 29+. For sharing, use FileProvider with a content URI and ACTION_SEND Intent type image/png, granting FLAG_GRANT_READ_URI_PERMISSION.

**Acceptance criteria:**
- Capture button is disabled before two measurement points are placed.
- After Capture, a PNG file exists in app cache and a MeasurementCapture row exists with the same file path.
- Entering 100 characters into the annotation field stores and renders only the first 80 characters.
- Save to Pictures creates a MediaStore image/png entry under Pictures/ClearTape.
- Share launches an ACTION_SEND chooser with type image/png and a content URI, not a file:// URI.

### Unit conversion and settings persistence

Lets users switch between metric and imperial units and keeps that choice across app restarts.

- **Answers complaint:** baseline parity

- **Screens:** Measure, Settings, Screenshot Editor

- **Estimated hours:** 16

**Implementation notes:** Persist one UserSettings row in Room with id 1. Default unitSystem is metric. Metric formatting: if distanceMeters < 1.0 display centimeters with one decimal, otherwise meters with two decimals. Imperial formatting: convert meters to inches by meters * 39.3700787402; if under 12 inches display inches with one decimal, otherwise display feet and inches as feet integer plus remaining inches with one decimal. The Settings radio buttons update UserSettings immediately through a DAO transaction. Measure observes settings via Flow and recomposes the visible distance without recreating AR anchors.

**Acceptance criteria:**
- Fresh install creates UserSettings id 1 with unitSystem metric.
- 1.0 meter formats as 1.00 m in metric.
- 0.5 meter formats as 50.0 cm in metric.
- 1.0 meter formats as 3 ft 3.4 in in imperial.
- After selecting Imperial, killing the app, and relaunching, Settings still shows Imperial and Measure formats in imperial units.

### $4.99 one-time unlock

Implements a transparent one-time Google Play Billing purchase instead of a weekly subscription.

- **Answers complaint:** Aggressive undisclosed billing $15-21/week charged on day one despite an advertised "free trial" — the single most common and severe complaint.

- **Screens:** Unlock, Settings, Measure

- **Estimated hours:** 40

**Implementation notes:** Create exactly one Play Billing in-app product with productId clear_tape_unlock and type INAPP. Do not create any SUBS product. On Unlock screen start BillingClient with enablePendingPurchases, queryProductDetailsAsync for clear_tape_unlock, and display the Play-returned one-time price; if unavailable, still show the fixed explanatory copy '$4.99 once' and disable purchase. Launch BillingFlow only after the user taps Unlock with Google Play. In PurchasesUpdatedListener, accept only Purchase.PurchaseState.PURCHASED for clear_tape_unlock, call acknowledgePurchase if not acknowledged, store PurchaseEntitlement.isUnlocked true with SHA-256 hash of purchaseToken, and call queryPurchasesAsync(INAPP) on every app start and Restore purchase. Never mention free trial, week, weekly, month, or subscription in purchasable product copy except in the explicit negative disclosure 'No weekly subscription.'

**Acceptance criteria:**
- The BillingClient product query uses ProductType.INAPP and productId clear_tape_unlock.
- No code path queries or launches a BillingFlow for ProductType.SUBS.
- Unlock copy visibly includes '$4.99 once' and 'No weekly subscription.'
- A PURCHASED result for clear_tape_unlock is acknowledged and sets PurchaseEntitlement.isUnlocked to true.
- A cancelled purchase leaves PurchaseEntitlement.isUnlocked false and returns the screen to not_purchased without blocking Measure.

### Billing and refund help

Provides an in-app explanation that there is no subscription to cancel and links users to Google Play purchase history.

- **Answers complaint:** No cancellation or refund path Google Play refunds only stop future charges, not the disputed first one, and in-app cancellation is often unavailable.

- **Screens:** Billing Help, Settings, Unlock

- **Estimated hours:** 12

**Implementation notes:** Add Billing Help screen reachable from Settings and Unlock. Static copy must state: 'This app has no subscription to cancel. The only paid item is a $4.99 one-time unlock handled by Google Play.' The Open Google Play purchase history button launches ACTION_VIEW with Uri.parse('https://play.google.com/store/account/orderhistory'). The Open Google Play subscriptions button launches ACTION_VIEW with Uri.parse('https://play.google.com/store/account/subscriptions') so users can verify there is no subscription. Catch ActivityNotFoundException and show an external_intent_error snackbar saying 'Could not open Google Play. Visit play.google.com/store/account in a browser.'

**Acceptance criteria:**
- Billing Help is reachable from Settings.
- Billing Help text states there is no subscription to cancel.
- Tapping purchase history starts an ACTION_VIEW intent for https://play.google.com/store/account/orderhistory.
- Tapping subscriptions starts an ACTION_VIEW intent for https://play.google.com/store/account/subscriptions.
- If no activity can handle the intent, an error snackbar is shown instead of crashing.

## Store listing

- **Title:** ClearTape AR Measure
- **Short description:** Measure in AR before paying. One $4.99 unlock, no weekly subscriptions.
- **Category:** Tools
- **Keywords:** AR measure, AR ruler, measuring tape, distance measurement, ARCore, room measure, one time purchase, no subscription
- **Icon prompt:** Create a clean Android app icon for an AR measuring tape app. Use a rounded square blue background (#1565C0), a simple white ruler glyph angled slightly upward, and two small AR point markers connected by a thin line. Flat vector style, high contrast, no text, no gradients, no brand logos.

**Long description:**

ClearTape AR Measure is an honest AR measuring tape built around a simple promise: measure first, pay only if you want the one-time unlock.

Use your phone camera to place two points on a detected surface and see the distance in metric or imperial units. A clear confidence indicator warns you when lighting, tracking, or surface detection may make the result less reliable.

What makes it different:
• Real free measuring before payment
• No weekly subscription
• No surprise free-trial charge
• Optional $4.99 one-time unlock through Google Play
• Measurement confidence indicator
• Save, annotate, and share measurement screenshots
• Measurements and screenshots stay on your device unless you share them

AR measurements depend on device support, lighting, surface texture, and ARCore tracking quality. ClearTape shows confidence warnings so you know when to remeasure.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://cleartape.example.com/privacy (privacy claims verified: no)
- **Data collected:** none

## Test plan

### 1. Paywall before first use Full-screen ads or payment screens shown before any use of the core measuring function. (instrumented)

1. Install the app with cleared data and no PurchaseEntitlement row.
2. Launch MainActivity.
3. Grant CAMERA permission when requested.
4. If ARCore install prompt appears, cancel the test as device setup failure; otherwise continue.
5. Observe the current Compose navigation route.
6. Search the visible node tree for text containing 'Unlock with Google Play'.
7. Search the visible node tree for text containing 'Continue measuring for free'.

**Expected:** The current route is measure, the full Unlock screen is not visible, and the Measure screen controls are visible without any automatic billing modal.

### 2. Aggressive undisclosed billing $15-21/week charged on day one despite an advertised "free trial". (unit)

1. Instantiate the Unlock screen copy provider used by the ViewModel.
2. Read the headline, price disclosure, and body text strings.
3. Concatenate the strings and convert to lowercase.
4. Check for the substrings 'week', '$15', '$21', 'trial', and 'subscription'.
5. Check for the substrings '$4.99 once' and 'no weekly subscription'.

**Expected:** The copy contains '$4.99 once' and 'no weekly subscription'; it does not contain '$15', '$21', or 'trial'. The only occurrence of subscription is in the negative disclosure that there is no weekly subscription.

### 3. Poor accuracy Measurements reported wildly off. (unit)

1. Create a distance calculation test with pose A translation floatArrayOf(0f, 0f, 0f).
2. Create pose B translation floatArrayOf(0f, 0f, 1f).
3. Call the distanceMeters function with those two translations.
4. Format the result with metric settings.
5. Repeat with pose B translation floatArrayOf(0f, 0f, 1.75f).

**Expected:** The first raw distance is 1.0 within 0.0001 and formats as 1.00 m. The second raw distance is 1.75 within 0.0001 and does not produce an impossible value such as 1110 cm.

### 4. Show a measurement confidence indicator. (unit)

1. Call computeConfidence with cameraTracking=false, anchorsTracking=true, planeTracking=true, pixelIntensity=0.8.
2. Call computeConfidence with all tracking true and pixelIntensity=0.2.
3. Call computeConfidence with all tracking true and pixelIntensity=0.5.
4. Call computeConfidence with all tracking true and pixelIntensity=0.8.

**Expected:** The returned levels are Low with reason Tracking lost, Low with reason Low light, Medium, and High respectively.

### 5. No cancellation or refund path. (instrumented)

1. Launch the app.
2. Navigate to Settings by tapping the Settings button on Measure.
3. Tap the Billing and refund help row.
4. Verify the Billing Help screen text.
5. Tap Open Google Play purchase history.
6. Intercept the outgoing intent with Espresso Intents.

**Expected:** Billing Help states 'This app has no subscription to cancel' and the outgoing intent action is ACTION_VIEW with URI https://play.google.com/store/account/orderhistory.

### 6. Unit conversion + settings persistence baseline-parity case. (unit)

1. Create an in-memory Room database.
2. Insert default UserSettings id 1 with metric.
3. Update unitSystem to imperial through the Settings DAO.
4. Close and reopen the DAO against the same database instance.
5. Read UserSettings id 1.
6. Format 1.0 meter using the stored unitSystem.

**Expected:** The stored unitSystem is imperial and 1.0 meter formats as 3 ft 3.4 in.

### 7. Save/share/annotate screenshots baseline-parity case. (instrumented)

1. Insert a MeasurementCapture row with a valid cache PNG path, distanceMeters 1.0, displayValue '1.00 m', and confidenceLevel 'high'.
2. Navigate directly to screenshot/{captureId}.
3. Type 'Desk width for shelf planning' into the annotation field.
4. Tap Share.
5. Intercept the outgoing ACTION_SEND intent.

**Expected:** The outgoing share intent has type image/png, includes EXTRA_STREAM with a content:// URI, and grants FLAG_GRANT_READ_URI_PERMISSION.

### 8. Wasn't even able to use it. (manual)

1. Install the release build on an ARCore-supported physical Android device.
2. Clear app data.
3. Launch the app in a normally lit room.
4. Grant camera permission.
5. Point the camera at a textured tabletop or floor until surface detection is ready.
6. Tap one point, then tap another point at least 30 cm away.
7. Do not open the Unlock screen or purchase anything.

**Expected:** A distance appears on the Measure screen before any purchase, and the user is not blocked by ads, subscriptions, or a full-screen payment screen.

## Build instructions

```sh
set -e
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit123 -keypass changeit123 -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClearTape Upload,O=ClearTape,C=US"
ANDROID_KEYSTORE_FILE="$PWD/release-upload.jks" ANDROID_KEYSTORE_PASSWORD="changeit123" ANDROID_KEY_ALIAS="upload" ANDROID_KEY_PASSWORD="changeit123" ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
