diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..e80c271 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,59 @@ +name: Unit tests + +# The safety net that was missing: compile + run the JVM unit suite (:app:testDebugUnitTest) on +# every PR that touches code and on every push to main. A stale or non-compiling test now FAILS +# the PR instead of slipping to production unnoticed (which is how the saveBoomerang assertion and +# the codec-settle constant rename drifted while the suite silently stopped compiling). +# +# Firebase is intentionally NOT required: app/build.gradle.kts applies the google-services / +# crashlytics plugins only when app/google-services.json exists (gitignored), so a fresh CI +# checkout builds and tests without it (analytics falls back to NoOp). + +on: + pull_request: + paths: + - '**/*.kt' + - '**/*.kts' + - 'gradle/**' + - 'gradle.properties' + - '.github/workflows/unit-tests.yml' + push: + branches: [ main ] + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: unit-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: JVM unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + # Repo is authored on Windows; gradlew is committed as mode 100644 (no +x bit), so it must be + # made executable before it can run on the Linux runner. + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run unit tests + run: ./gradlew --no-daemon :app:testDebugUnitTest + + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-test-report + path: app/build/reports/tests/testDebugUnitTest + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 9c44eb0..055cc02 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,9 @@ local/ # Files generated by Gradle .gradle /local.properties -/gradle/wrapper/gradle-wrapper.jar +# NOTE: gradle/wrapper/gradle-wrapper.jar is intentionally NOT ignored — Gradle requires the +# wrapper jar to be committed so fresh clones and CI can build via ./gradlew (it was wrongly +# ignored here, which broke CI and first-time builds). # Kotlin build-session data (Kotlin Gradle plugin 2.x) .kotlin/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 198579d..0cf07ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "io.github.stozo04.openloop" minSdk = 26 targetSdk = 36 - versionCode = 23 - versionName = "1.0.23" + versionCode = 24 + versionName = "1.0.24" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsTest.kt b/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsTest.kt index 5d9dc68..5946015 100644 --- a/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsTest.kt +++ b/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsTest.kt @@ -9,8 +9,8 @@ class DeviceMediaHintsTest { @Test fun samsungPreviewReverseCap_isBelowExportCap() { assertEquals(480, SAMSUNG_PREVIEW_REVERSE_MAX_SHORT_SIDE) - assertEquals(500L, SAMSUNG_POST_TRANSFORM_CODEC_SETTLE_MS) - assertTrue(PRE_REVERSE_CODEC_SETTLE_MS >= 200L) + assertEquals(500L, SAMSUNG_POST_TRANSFORM_CODEC_SETTLE.inWholeMilliseconds) + assertTrue(PRE_REVERSE_CODEC_SETTLE.inWholeMilliseconds >= 200L) assert(SAMSUNG_PREVIEW_REVERSE_MAX_SHORT_SIDE < MAX_OUTPUT_SHORT_SIDE) } } diff --git a/app/src/test/java/io/github/stozo04/openloop/media/SamsungReversePreviewRegressionTest.kt b/app/src/test/java/io/github/stozo04/openloop/media/SamsungReversePreviewRegressionTest.kt index 0cf69e5..8f31322 100644 --- a/app/src/test/java/io/github/stozo04/openloop/media/SamsungReversePreviewRegressionTest.kt +++ b/app/src/test/java/io/github/stozo04/openloop/media/SamsungReversePreviewRegressionTest.kt @@ -33,7 +33,7 @@ class SamsungReversePreviewRegressionTest { /** Log: Transformer Release 28.303 → reverse start 28.324 — settle must be non-zero. */ @Test fun postTransformSettleMs_isPositive() { - assertTrue(SAMSUNG_POST_TRANSFORM_CODEC_SETTLE_MS >= 200L) + assertTrue(SAMSUNG_POST_TRANSFORM_CODEC_SETTLE.inWholeMilliseconds >= 200L) } /** diff --git a/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt b/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt index 117c5f5..d62f3eb 100644 --- a/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt +++ b/app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt @@ -108,6 +108,15 @@ class FakeVideoStorageRepository : VideoStorageRepository { /** UUIDs passed to [discardScratch], for assertions. */ val discardedScratches = mutableListOf() + /** + * Id minted by the most recent [promoteScratchToRaw]. Save tests assert the registered + * boomerang's [RecordedVideo.sourceRawId] links back to the raw it was promoted from — even + * though the raw row itself is deleted after a successful render (BoomerangRenderWorker: + * "the original raw video is no longer needed after a successful boomerang render"). + */ + var lastPromotedRawId: Long = -1L + private set + /** Count of [createScratchCapture] calls, so import tests can assert "no scratch was minted". */ var createScratchCount: Int = 0 private set @@ -142,6 +151,7 @@ class FakeVideoStorageRepository : VideoStorageRepository { override suspend fun promoteScratchToRaw(scratch: ScratchCapture): RecordedVideo? { if (failPromote) return null val id = nextId++ + lastPromotedRawId = id return RecordedVideo( id = id, videoPath = File(tempRoot, "clip_$id.mp4").absolutePath, @@ -1063,10 +1073,16 @@ class OpenLoopViewModelTest { assertEquals(1, fakeRenderScheduler.enqueueCount) assertEquals(1, fakeVideoProcessor.renderCount) - // One RAW (promoted) + one BOOMERANG (registered), boomerang points at the raw. - val raw = fakeVideoStorage.saved.single { it.kind == VideoKind.RAW } + // The render worker registers the BOOMERANG, then deletes the now-intermediate RAW + // (BoomerangRenderWorker: "the original raw video is no longer needed after a successful + // boomerang render"). So after save: exactly one BOOMERANG remains, no RAW, and the + // boomerang still links back to the raw it was promoted from. val boomerang = fakeVideoStorage.saved.single { it.kind == VideoKind.BOOMERANG } - assertEquals(raw.id, boomerang.sourceRawId) + assertTrue( + "raw must be cleaned up after a successful render", + fakeVideoStorage.saved.none { it.kind == VideoKind.RAW }, + ) + assertEquals(fakeVideoStorage.lastPromotedRawId, boomerang.sourceRawId) assertEquals(1, fakeVideoStorage.discardedScratches.size) // scratch cleaned up after save assertNull(viewModel.editorState.value) diff --git a/docs/play-store/eu-geo-blocking-compliance.md b/docs/play-store/eu-geo-blocking-compliance.md new file mode 100644 index 0000000..60f4567 --- /dev/null +++ b/docs/play-store/eu-geo-blocking-compliance.md @@ -0,0 +1,69 @@ +# OpenLoop — EU Geo-blocking Regulation (EU) 2018/302 compliance record + +When you set **Play Console → Production → Countries/regions** to "the world," Play surfaces an +informational notice linking to +[Geo-blocking Regulation (EU) 2018/302](https://support.google.com/googleplay/android-developer/answer/6223646). +This file is the source-of-truth record that OpenLoop was audited against that regulation and the +basis for the "compliant" conclusion. Keep it in sync if the app ever gains location awareness, +region-gated features, or in-app payments. + +> **Verdict (audited at versionCode 24 / 1.0.24):** Compliant **by construction** — no code changes +> required. The regulation bans *unjustified discrimination based on a user's nationality, place of +> residence, or place of establishment* across three axes. OpenLoop has **zero surface** on all three: +> it cannot determine where a user is, has no region-gated features, and takes no payments. You can't +> violate an anti-geo-discrimination rule with an app that has no geo-awareness and no commercial +> transaction. + +--- + +## Code audit — three regulation axes + +Swept all Kotlin source, the Gradle/version-catalog dependencies, and the merged manifest. + +| Regulation axis | What was searched | Finding | +|---|---|---| +| **1. Block / redirect access by location** | `Locale`, `getCountry`, SIM/network-country, `TelephonyManager`, location permission, IP-geo, Remote Config, any HTTP client | **None.** No `ACCESS_*_LOCATION` permission in the manifest, no telephony/SIM-country reads, no networking layer. The only `https://` occurrences are doc-comment links; the only off-device traffic is Firebase Analytics/Crashlytics telemetry, which does **not** gate the app. | +| **2. Region-locked features** | feature flags, Remote Config, `fetchAndActivate`, geographic branches | **None.** No Remote Config dependency. Processing is 100% on-device. Every user receives the identical app. | +| **3. Payment discrimination** | `BillingClient`, IAP, subscription, SKU, paywall, premium, price | **None.** Zero billing surface — no Play Billing library, no in-app purchases, no paywall. The app is free everywhere. | + +**The only `Locale` usage** is `Locale.US` for deterministic number/time *formatting* (e.g. +`String.format(Locale.US, "%02d:%04.1f", …)` in the trim/speed UI). That is display formatting applied +identically to every user — the opposite of region gating, and a best practice (avoids locale-dependent +decimal separators). It is intentionally retained. + +**Manifest confirmation:** declared permissions are `CAMERA`, `FOREGROUND_SERVICE`, +`FOREGROUND_SERVICE_MEDIA_PROCESSING`, `POST_NOTIFICATIONS`, and `AD_ID` is *removed* +(`tools:node="remove"`). With no location permission, the app physically cannot read a user's country, +nationality, or residence. + +--- + +## Non-code checklist — where this regulation actually lives + +2018/302 is overwhelmingly a **distribution & commercial-terms** rule, not a code rule. + +| # | Item | Status | +|---|---|---| +| 1 | **Country/region availability = "the world"** (all EU member states included equally) | ✅ Done — the single operative control, set correctly. | +| 2 | **No different general terms & conditions for EU users** by nationality/residence | ✅ Free app, no account/login, no region-specific terms. | +| 3 | **Privacy policy reachable by all EU users** | ✅ Public URL (`docs/privacy-policy.html`), accessible everywhere. | +| 4 | **No redirecting EU users** to a different listing/version without consent | ✅ N/A — single global listing. | +| 5 | **Payment-method non-discrimination** (no refusing/varying terms by payment-account location) | ⚠️ N/A today (no payments). **Future watch-item:** revisit if the app ever monetizes — payment terms must not differ by nationality, residence, or payment-account location. | + +--- + +## Bottom line + +- **Code:** nothing to change. +- **Non-code:** the only control that matters — Play Console country/region = "the world" — is already set. +- **Re-audit trigger:** revisit this record only if a future change adds (a) location awareness, (b) a + region-gated feature / Remote Config, or (c) in-app purchases. + +## Sources + +- [Geo-blocking Regulation (EU) 2018/302 — Play Console Help](https://support.google.com/googleplay/android-developer/answer/6223646) +- [Regulation (EU) 2018/302 — full text (EUR-Lex)](https://eur-lex.europa.eu/eli/reg/2018/302/oj) + +--- + +*Audited 2026-06-19 against versionCode 24 (1.0.24). Re-run the three-axis sweep above if location, region-gating, or payment surfaces are ever introduced.* diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f5b7f9..8c4d8bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ agp = "9.2.1" kotlin = "2.3.21" coreKtx = "1.12.0" lifecycleKtx = "2.7.0" -activityCompose = "1.8.2" +activityCompose = "1.13.0" composeBom = "2026.05.01" camerax = "1.6.1" media3 = "1.10.1" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ