diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..296afdc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: Build + +# Runs on every push and pull request. Needs no secrets: it builds unsigned artefacts and, +# to prove the release signing path works, signs once with a throw-away key made on the fly. + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Unit tests (Google Play flavor logic) + run: ./gradlew --no-daemon :app:testPlayDebugUnitTest + + - name: Build F-Droid / GitHub APK, Play debug APK and Play bundle + run: ./gradlew --no-daemon :app:assembleFossRelease :app:assemblePlayDebug :app:bundlePlayRelease + + - name: The foss build must stay free of network access and Google code + run: | + APK=$(ls app/build/outputs/apk/foss/release/*.apk | head -n 1) + AAPT2=$(ls -d "$ANDROID_HOME"/build-tools/*/aapt2 | sort -V | tail -n 1) + echo "Checking $APK" + "$AAPT2" dump permissions "$APK" | tee permissions.txt + if grep -qE 'INTERNET|ACCESS_NETWORK_STATE|AD_ID' permissions.txt; then + echo "::error::foss build declares a network/ads permission"; exit 1 + fi + if unzip -p "$APK" 'classes*.dex' | grep -aq 'com/google/android/gms'; then + echo "::error::foss build contains Google Play services code"; exit 1 + fi + echo "foss build is clean" + + - name: The play build must declare what ads and billing need + run: | + APK=$(ls app/build/outputs/apk/play/debug/*.apk | head -n 1) + AAPT2=$(ls -d "$ANDROID_HOME"/build-tools/*/aapt2 | sort -V | tail -n 1) + "$AAPT2" dump permissions "$APK" | tee play-permissions.txt + for p in INTERNET ACCESS_NETWORK_STATE AD_ID; do + grep -q "$p" play-permissions.txt || { echo "::error::play build is missing $p"; exit 1; } + done + + - name: Signing smoke test with a throw-away key + env: + RELEASE_STORE_PASSWORD: throwaway-pass + RELEASE_KEY_ALIAS: smoke + RELEASE_KEY_PASSWORD: throwaway-pass + run: | + keytool -genkeypair -keystore "$RUNNER_TEMP/smoke.jks" -storepass throwaway-pass \ + -keypass throwaway-pass -alias smoke -keyalg RSA -keysize 2048 -validity 2 \ + -dname "CN=smoke test" + export RELEASE_STORE_FILE="$RUNNER_TEMP/smoke.jks" + ./gradlew --no-daemon :app:bundlePlayRelease + AAB=$(ls app/build/outputs/bundle/playRelease/*.aab | head -n 1) + jarsigner -verify -certs "$AAB" | head -n 20 + jarsigner -verify "$AAB" | grep -q "jar verified" + + - name: Upload the Play debug APK for manual testing + uses: actions/upload-artifact@v4 + with: + name: play-debug-apk + path: app/build/outputs/apk/play/debug/*.apk + retention-days: 7 diff --git a/.github/workflows/release-aab.yml b/.github/workflows/release-aab.yml new file mode 100644 index 0000000..fe68a59 --- /dev/null +++ b/.github/workflows/release-aab.yml @@ -0,0 +1,76 @@ +name: Release AAB for Google Play + +# Builds the signed Android App Bundle you upload to the Play Console. The signing key never +# touches the repository: it is read from encrypted repository secrets (Settings > Secrets +# and variables > Actions), written to a temporary file for the build and deleted after. +# +# Required secrets: +# RELEASE_KEYSTORE_BASE64 the .jks file, base64-encoded +# RELEASE_STORE_PASSWORD keystore password +# RELEASE_KEY_ALIAS key alias (bcam) +# RELEASE_KEY_PASSWORD key password +# +# Run it from the Actions tab (Run workflow). The result is the "play-release-aab" artefact +# at the bottom of the run page. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bundle: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v5 + + - name: Restore the signing key + env: + KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: | + for v in KEYSTORE_B64 STORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do + if [ -z "${!v}" ]; then + echo "::error::Repository secret for $v is not set - see the comment at the top of this workflow" + exit 1 + fi + done + echo "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/release.jks" + { + echo "RELEASE_STORE_FILE=$RUNNER_TEMP/release.jks" + echo "RELEASE_STORE_PASSWORD=$STORE_PASSWORD" + echo "RELEASE_KEY_ALIAS=$KEY_ALIAS" + echo "RELEASE_KEY_PASSWORD=$KEY_PASSWORD" + } >> "$GITHUB_ENV" + + - name: Build the signed bundle + run: ./gradlew --no-daemon :app:bundlePlayRelease + + - name: Show which key signed it + run: | + AAB=$(ls app/build/outputs/bundle/playRelease/*.aab | head -n 1) + jarsigner -verify "$AAB" | head -n 3 + keytool -printcert -jarfile "$AAB" | grep -E 'Owner|SHA1|SHA256|Valid' + + - name: Upload the bundle + uses: actions/upload-artifact@v4 + with: + name: play-release-aab + path: app/build/outputs/bundle/playRelease/*.aab + if-no-files-found: error + retention-days: 30 + + - name: Delete the key from the runner + if: always() + run: rm -f "$RUNNER_TEMP/release.jks" diff --git a/.gitignore b/.gitignore index d6384c7..58ca191 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,12 @@ build/ .externalNativeBuild .cxx local.properties -RELEASE_STORE_FILE=C:/GoshanData/keys/bcam-release.jks -RELEASE_STORE_PASSWORD=bo4A4FqxwYQ7p5L -RELEASE_KEY_ALIAS=bcam -RELEASE_KEY_PASSWORD=bo4A4FqxwYQ7p5L + +# Signing material and release artefacts never belong in the repository. +*.jks +*.keystore +*.p12 +*.aab # Locally staged APKs for testing /apk/ diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..b1fd323 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,193 @@ +# Privacy policy — Background Camera + +*Last updated: 11 October 2026 · [Русская версия ниже](#политика-конфиденциальности--background-camera)* + +Background Camera (`io.github.holego.bcam`) is a video recorder for Android developed by +**Holego**. This policy covers every version of the app: the one on Google Play, and the one +on F-Droid and GitHub. + +## In short + +* Your recordings, and the app's settings, **stay on your device**. The app never uploads + them anywhere, and the developer has no server that could receive them. +* The app has **no account, no analytics and no crash reporting of its own.** +* The **F-Droid / GitHub version has no network access at all** and no ads. +* The **Google Play version shows ads (Google AdMob)** and offers a one-time purchase that + removes them. Those two features use the internet and are the only reason this policy has + more than a few lines. + +## What stays on your device + +| Data | Where it lives | Leaves the device? | +|---|---|---| +| Videos and, if you enable it, their audio | The folder you pick (Android Storage Access Framework) | **No** | +| Settings (folder, camera, quality, language…) | The app's private storage | **No** | + +Removing the app deletes the settings. The videos are ordinary files in your folder; the app +never deletes them, and you manage them yourself. + +## Google Play version: ads + +The free Google Play version shows ads provided by **Google AdMob** (Google LLC). To choose, +deliver and measure ads, and to prevent fraud, the Google Mobile Ads SDK in the app may +collect and use, according to the consent you give: + +* the **advertising ID** of your device (you can reset or delete it in Android settings → + Privacy → Ads); +* your **IP address** and the **approximate location** derived from it; +* device and app information (model, OS version, language, app version); +* how you interact with ads (impressions, taps), and crash/diagnostic data from the SDK. + +This data is processed by Google under [Google's privacy policy](https://policies.google.com/privacy); +see also [how Google uses data from apps that use its services](https://policies.google.com/technologies/partner-sites). +The developer does not receive this data, only aggregated ad revenue reports. + +**Consent.** If you are in the European Economic Area, the United Kingdom or Switzerland, the +app first shows Google's consent message and only requests ads according to your choice. You +can review or change that choice at any time under **Ad privacy settings** on the main screen. +Elsewhere you can still reset your advertising ID in Android settings. + +Ads are shown only on the setup screen, at natural pauses. **They are never shown while a +recording is running, and never when you start or stop a recording from the widget or the +notification.** + +**Not for children.** The app is not directed to children under 13 (or under the applicable +age of digital consent) and does not knowingly serve ads to them. + +## Google Play version: the "remove ads" purchase + +The purchase is handled entirely by **Google Play Billing**. The developer never sees your +payment details. The app only learns from Google Play whether you own the product, and keeps +a local flag so it can hide ads. If you own it, the ad SDK is not started at all. A purchase +can be restored on a new device through the same Google account. + +Optionally, you can watch a short rewarded video to turn ads off for 24 hours; the time of +that reward is stored on the device only. + +## Permissions + +| Permission | Why | +|---|---| +| Camera | To record video. | +| Microphone | Only if you turn on *Record audio*. | +| Foreground service (camera, microphone) | Keeps a recording running with the screen off; it always shows a notification with a Stop button. | +| Notifications | To show that a recording is in progress. | +| Wake lock | So a recording is not cut off when the screen turns off. | +| Internet, network state, advertising ID *(Google Play version only)* | Ads and the purchase. Not present in the F-Droid / GitHub version. | + +## Responsible use + +Recording people may require their consent and is regulated by law in many places. You are +responsible for using the app lawfully. While a recording runs the app always shows a +notification, and Android itself shows camera and microphone indicators. + +## Your rights and contact + +Because the developer does not hold personal data about you, there is nothing to access or +erase on the developer's side. For ad-related data, use **Ad privacy settings** in the app, +your Android advertising settings, and your Google account's +[Ad settings](https://adssettings.google.com). + +Questions: open an issue at or write to the Jabber +address `Jopirat@jabber.fr`. + +## Changes + +If this policy changes, the new version will appear at this address with a new date. + +--- + +# Политика конфиденциальности — Background Camera + +*Обновлено: 11 октября 2026* + +Background Camera (`io.github.holego.bcam`) — приложение для записи видео на Android, +разработчик — **Holego**. Политика относится ко всем версиям приложения: из Google Play и из +F-Droid / GitHub. + +## Коротко + +* Ваши записи и настройки приложения **остаются на вашем устройстве**. Приложение никуда их не + отправляет, а у разработчика нет сервера, который мог бы их принять. +* **Нет аккаунта, нет собственной аналитики и отчётов о сбоях.** +* **Версия из F-Droid / GitHub вообще не имеет доступа к сети** и не содержит рекламы. +* **Версия из Google Play показывает рекламу (Google AdMob)** и предлагает разовую покупку, + которая её отключает. Только эти две функции используют интернет. + +## Что остаётся на устройстве + +| Данные | Где хранятся | Покидают устройство? | +|---|---|---| +| Видео и, если включено, звук | Выбранная вами папка (Storage Access Framework) | **Нет** | +| Настройки (папка, камера, качество, язык…) | Приватное хранилище приложения | **Нет** | + +При удалении приложения настройки удаляются. Видео — обычные файлы в вашей папке: приложение их +не удаляет, вы управляете ими сами. + +## Версия Google Play: реклама + +Бесплатная версия в Google Play показывает рекламу **Google AdMob** (Google LLC). Чтобы +подбирать, показывать и учитывать рекламу и предотвращать мошенничество, Google Mobile Ads SDK +может собирать и использовать — в соответствии с вашим согласием: + +* **рекламный идентификатор** устройства (сбросить или удалить его можно в настройках Android → + Конфиденциальность → Реклама); +* **IP-адрес** и определяемое по нему **приблизительное местоположение**; +* сведения об устройстве и приложении (модель, версия ОС, язык, версия приложения); +* взаимодействие с рекламой (показы, нажатия) и диагностические данные SDK. + +Эти данные обрабатывает Google в соответствии с +[политикой конфиденциальности Google](https://policies.google.com/privacy). Разработчик эти +данные не получает — только сводные отчёты о доходе от рекламы. + +**Согласие.** Если вы находитесь в Европейской экономической зоне, Великобритании или +Швейцарии, приложение сначала показывает сообщение о согласии Google и запрашивает рекламу +только в соответствии с вашим выбором. Изменить выбор можно в любой момент: пункт **«Настройки +конфиденциальности рекламы»** на главном экране. В других регионах рекламный идентификатор можно +сбросить в настройках Android. + +Реклама показывается только на экране настроек, в естественных паузах. **Её никогда нет во +время записи, а также при запуске и остановке записи через виджет или уведомление.** + +**Не для детей.** Приложение не предназначено для детей младше 13 лет (или младше возраста +цифрового согласия, установленного законом) и сознательно не показывает им рекламу. + +## Версия Google Play: покупка «отключить рекламу» + +Покупку полностью обрабатывает **Google Play Billing**. Разработчик не видит ваши платёжные +данные. Приложение лишь узнаёт от Google Play, куплен ли товар, и хранит локальную отметку, +чтобы скрывать рекламу. Если покупка есть, рекламный SDK вообще не запускается. Покупку можно +восстановить на новом устройстве через тот же аккаунт Google. + +Дополнительно можно посмотреть короткое видео и отключить рекламу на 24 часа; время такой +награды хранится только на устройстве. + +## Разрешения + +| Разрешение | Зачем | +|---|---| +| Камера | Запись видео. | +| Микрофон | Только если включён пункт «Записывать звук». | +| Служба переднего плана (камера, микрофон) | Запись продолжается при выключенном экране; при этом всегда показано уведомление с кнопкой «Стоп». | +| Уведомления | Показать, что идёт запись. | +| Wake lock | Чтобы запись не прерывалась при выключении экрана. | +| Интернет, состояние сети, рекламный идентификатор *(только версия Google Play)* | Реклама и покупка. В версии F-Droid / GitHub их нет. | + +## Ответственное использование + +Запись людей может требовать их согласия и регулируется законом во многих странах. Вы сами +отвечаете за законное использование приложения. Во время записи приложение всегда показывает +уведомление, а Android — индикаторы камеры и микрофона. + +## Ваши права и связь + +У разработчика нет ваших персональных данных, поэтому на его стороне нечего запрашивать или +удалять. Для данных, связанных с рекламой, используйте **«Настройки конфиденциальности +рекламы»** в приложении, настройки рекламы Android и +[настройки рекламы](https://adssettings.google.com) аккаунта Google. + +Вопросы: или Jabber `Jopirat@jabber.fr`. + +## Изменения + +При изменении политики новая версия появится по этому адресу с новой датой. diff --git a/README.md b/README.md index 808c328..b13f32b 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,34 @@ app** — from a home-screen widget or the Stop button in the notification. It k recording with the screen off and does nothing else. Kotlin + CameraX + Foreground Service + Storage Access Framework. No Compose, no WebView, -no Flutter, no React Native, no heavy third-party libraries. +no Flutter, no React Native, no heavy third-party libraries. + +Two distributions are built from this source tree: + +| Flavor | Where | What it contains | +|---|---|---| +| `foss` | F-Droid, GitHub | The app as described below: no ads, no billing, **no `INTERNET` permission**, no proprietary code. | +| `play` | Google Play | The same app plus Google Mobile Ads (AdMob), Google's consent form (UMP) and Play Billing for a one-time "remove ads" purchase. Everything extra lives in `app/src/play`; `app/src/main` knows nothing about it. | + +Unless a section says otherwise, this document describes the `foss` build. For the privacy +policy of both see [PRIVACY.md](PRIVACY.md); for publishing to Google Play see +[docs/google-play](docs/google-play/README.md). --- ## Building ```bash -gradlew assembleDebug # installable, debug-signed, debuggable -gradlew assembleRelease # unsigned unless a keystore is configured +gradlew assembleFossDebug # installable, debug-signed, debuggable +gradlew assembleFossRelease # F-Droid / GitHub APK; unsigned unless a keystore is configured +gradlew bundlePlayRelease # .aab for the Play Console; signed if a keystore is configured +gradlew assemblePlayDebug # Play flavor with Google's sample (test) ads +gradlew testPlayDebugUnitTest # unit tests for the ad rules ``` -The APK declares no `INTERNET` permission. +The `foss` APK declares no `INTERNET` permission, and CI checks that on every push. Debug +builds of the `play` flavor use Google's sample ad units, so developing never produces real +ad traffic; only `release` builds carry the live ad unit ids. ### Signing a release build @@ -43,30 +59,38 @@ APK, so cloning and building the project never requires a key. Keep this keystore safe and backed up: Android identifies an app by package name **and** signing key, so losing it means no future version can update an installed one. +### Building the Play bundle in the cloud + +The *Release AAB for Google Play* workflow (Actions tab → *Run workflow*) builds the signed +`.aab` on GitHub's runners, so nothing has to be installed locally. It reads the key from four +repository secrets — `RELEASE_KEYSTORE_BASE64`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, +`RELEASE_KEY_PASSWORD` — and the result is the `play-release-aab` artefact of the run. + --- ## Build requirements -JDK 17 (AGP 8.5 does not run on JDK 25) and Android SDK Platform 34. +JDK 17 and Android SDK Platform 36 (JDK 25 is too new for this AGP). > Keep `!` out of the project path. Kotlin's compiler treats `!/` as the JAR-entry > separator and fails on the generated `R.jar`. Android Studio behaves the same way. | Component | Version | |---|---| -| Android Gradle Plugin | 8.5.2 | -| Gradle | 8.7 | -| Kotlin | 1.9.24 | -| compileSdk / targetSdk | 34 | +| Android Gradle Plugin | 8.13.0 | +| Gradle | 8.13 | +| Kotlin | 2.2.20 | +| compileSdk / targetSdk | 36 (Google Play requires 36 for new releases from 31 Aug 2026) | | minSdk | 26 | | CameraX | 1.3.4 | +| Google Mobile Ads SDK / Play Billing *(play flavor only)* | 25.4.0 / 9.1.0 | --- ## Layout ``` -app/src/main/java/com/example/videorecorder/ +app/src/main/java/io/github/holego/bcam/ ├─ MainActivity.kt setup screen and manual start/stop ├─ AboutActivity.kt what the app is, privacy, contact links ├─ recording/ @@ -82,6 +106,15 @@ app/src/main/java/com/example/videorecorder/ │ └─ SettingsRepository.kt SharedPreferences └─ widget/ └─ RecordingWidgetProvider.kt home-screen button + +app/src/play/…/monetization/ Google Play flavor only +├─ MonetizationController.kt the four calls MainActivity makes; wires everything below +├─ AdsManager.kt banner, interstitial, rewarded (+ interstitial), native, app-open +├─ AdPolicy.kt when ads may show (pure Kotlin, unit-tested) +├─ ConsentManager.kt Google's UMP consent form +├─ BillingManager.kt the one-time "remove_ads" product +└─ AdUnits.kt ad unit ids (test units in debug, live units in release) +app/src/foss/…/monetization/ an empty MonetizationController with the same API ``` All active recording lives in `RecordingService`. `MainActivity` only configures and sends @@ -213,6 +246,9 @@ the camera advertises are offered, and the setting defaults to Auto. ## Privacy +*This section describes the `foss` (F-Droid / GitHub) build. The Google Play build adds ads +and a purchase and therefore uses the network; see [PRIVACY.md](PRIVACY.md).* + The app collects, stores and transmits nothing. No account, no analytics, no crash reporting, no telemetry, and no `INTERNET` permission at all. Videos are written straight into the folder you choose and never leave the device. Settings live in the app's private diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0602ed..0e7700a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -29,16 +30,48 @@ fun signingProp(name: String): String? = val hasReleaseKeystore = signingProp("RELEASE_STORE_FILE") != null +/* + * AdMob application ids. Debug builds use Google's public sample id so that developing + * and testing never produces real ad traffic - clicking your own live ads is a policy + * violation that can get an AdMob account suspended. The live id belongs to the app + * registered in AdMob; ad *unit* ids live in AdUnits.kt (play flavor). + */ +val admobAppIdTest = "ca-app-pub-3940256099942544~3347511713" +val admobAppIdLive = "ca-app-pub-6234952902147314~5684803993" + android { namespace = "io.github.holego.bcam" - compileSdk = 34 + // Google Play requires new apps and updates to target API 36 from 31 Aug 2026. + compileSdk = 36 defaultConfig { applicationId = "io.github.holego.bcam" minSdk = 26 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" + targetSdk = 36 + versionCode = 2 + versionName = "1.1" + } + + /* + * Two distributions of the same app: + * + * foss - F-Droid / GitHub. No ads, no billing, no INTERNET permission, no + * proprietary dependency. This is what the privacy statements in the README + * and the F-Droid metadata describe. + * play - Google Play. Adds Google Mobile Ads (AdMob), the UMP consent form and Play + * Billing for the one-time "remove ads" purchase. Everything it adds lives in + * src/play; src/main has no reference to any of it. + * + * Build: assembleFossRelease (APK) bundlePlayRelease (AAB for Play Console) + */ + flavorDimensions += "distribution" + productFlavors { + create("foss") { + dimension = "distribution" + } + create("play") { + dimension = "distribution" + } } signingConfigs { @@ -58,7 +91,11 @@ android { } buildTypes { + debug { + manifestPlaceholders["admobAppId"] = admobAppIdTest + } release { + manifestPlaceholders["admobAppId"] = admobAppIdLive if (hasReleaseKeystore) { signingConfig = signingConfigs.getByName("release") } @@ -85,13 +122,23 @@ android { buildConfig = true } - // AGP embeds a "Dependency metadata" block into the APK's signing block, meant for - // the Play Console's dependency report. It is not a standard part of the APK format, - // and F-Droid's scanner flags it as an unrecognised extra signing block, so it is - // turned off - this app is not distributed through Play anyway. + // AGP embeds a "Dependency metadata" block into the APK's signing block. It is not a + // standard part of the APK format and F-Droid's scanner flags it as an unrecognised + // extra signing block, so it stays out of APKs. In an App Bundle it is the opposite: + // Play reads it for its SDK/vulnerability reporting, and the bundle is never + // distributed anywhere else. dependenciesInfo { includeInApk = false - includeInBundle = false + includeInBundle = true + } + + bundle { + // The app has an in-app language switch (English / Russian). With language splits + // Play would deliver only the device language, and the other one would silently + // stop working. The strings are tiny, so ship all of them. + language { + enableSplit = false + } } compileOptions { @@ -99,8 +146,30 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +// CameraX's ProcessCameraProvider.getInstance() returns a Guava ListenableFuture, which the +// app uses directly. The ads and billing libraries drag in +// "listenablefuture:9999.0-empty-to-avoid-conflict-with-guava"; being the higher version it +// wins conflict resolution, and being empty it removes the class from the *compile* +// classpath. Pinning the real 1.0 artifact (just the one interface) there fixes that. +// +// Compile classpaths only: at runtime the play flavor already contains the full Guava, and +// forcing 1.0 there as well would put ListenableFuture on the classpath twice (the duplicate +// class check fails the build). Without the play libraries 1.0 is what resolves anyway, so +// none of this changes the foss build. +configurations.configureEach { + if (name.endsWith("CompileClasspath", ignoreCase = true)) { + resolutionStrategy.force("com.google.guava:listenablefuture:1.0") } } @@ -123,4 +192,11 @@ dependencies { // Storage Access Framework helper implementation("androidx.documentfile:documentfile:1.0.1") + + // Google Play distribution only. None of this is on the foss classpath. + "playImplementation"("com.google.android.gms:play-services-ads:25.4.0") + "playImplementation"("com.google.android.ump:user-messaging-platform:3.2.0") + "playImplementation"("com.android.billingclient:billing:9.1.0") + + testImplementation("junit:junit:4.13.2") } diff --git a/app/src/foss/java/io/github/holego/bcam/monetization/MonetizationController.kt b/app/src/foss/java/io/github/holego/bcam/monetization/MonetizationController.kt new file mode 100644 index 0000000..48e9af8 --- /dev/null +++ b/app/src/foss/java/io/github/holego/bcam/monetization/MonetizationController.kt @@ -0,0 +1,19 @@ +package io.github.holego.bcam.monetization + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +/** + * The F-Droid / GitHub build has no ads and no purchases, so this is deliberately empty. It + * exists so that MainActivity can call the same four methods in both flavors; the real + * implementation, with the ad and billing SDKs behind it, lives in src/play. + */ +@Suppress("UNUSED_PARAMETER") +internal class MonetizationController(activity: AppCompatActivity) { + + fun attach(savedInstanceState: Bundle?) = Unit + + fun onLeavingForSystemScreen() = Unit + + fun onRecordingStoppedByUser() = Unit +} diff --git a/app/src/main/java/io/github/holego/bcam/AboutActivity.kt b/app/src/main/java/io/github/holego/bcam/AboutActivity.kt index 0644fa7..2a3c323 100644 --- a/app/src/main/java/io/github/holego/bcam/AboutActivity.kt +++ b/app/src/main/java/io/github/holego/bcam/AboutActivity.kt @@ -8,7 +8,11 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import io.github.holego.bcam.databinding.ActivityAboutBinding /** @@ -19,12 +23,21 @@ class AboutActivity : AppCompatActivity() { private lateinit var binding: ActivityAboutBinding override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) binding = ActivityAboutBinding.inflate(layoutInflater) setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding(bars.left, bars.top, bars.right, bars.bottom) + WindowInsetsCompat.CONSUMED + } binding.version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME) + binding.rowPrivacyPolicy.setOnClickListener { open(PRIVACY_POLICY_URL) } binding.rowGithub.setOnClickListener { open(GITHUB_URL) } binding.rowJabber.setOnClickListener { openJabber() } @@ -63,6 +76,9 @@ class AboutActivity : AppCompatActivity() { companion object { private const val GITHUB_URL = "https://github.com/Holego" + + /** Also entered as the privacy policy URL in the Play Console. */ + private const val PRIVACY_POLICY_URL = "https://github.com/Holego/BCam/blob/main/PRIVACY.md" private const val JABBER_ID = "Jopirat@jabber.fr" } } diff --git a/app/src/main/java/io/github/holego/bcam/MainActivity.kt b/app/src/main/java/io/github/holego/bcam/MainActivity.kt index 503b260..e9f8352 100644 --- a/app/src/main/java/io/github/holego/bcam/MainActivity.kt +++ b/app/src/main/java/io/github/holego/bcam/MainActivity.kt @@ -9,12 +9,16 @@ import android.os.Bundle import android.util.Log import android.view.View import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -23,6 +27,7 @@ import io.github.holego.bcam.camera.CameraRepository import io.github.holego.bcam.camera.CompressionOption import io.github.holego.bcam.camera.VideoQualityOption import io.github.holego.bcam.databinding.ActivityMainBinding +import io.github.holego.bcam.monetization.MonetizationController import io.github.holego.bcam.recording.RecordingService import io.github.holego.bcam.recording.RecordingState import io.github.holego.bcam.recording.RecordingStateHolder @@ -47,6 +52,7 @@ class MainActivity : AppCompatActivity() { private lateinit var settings: SettingsRepository private lateinit var storage: VideoStorageManager private lateinit var cameraRepository: CameraRepository + private lateinit var monetization: MonetizationController private var cameras: List = emptyList() @@ -57,6 +63,14 @@ class MainActivity : AppCompatActivity() { */ private var folderUsable = false + /** + * Only used to tell "the user pressed Stop in this screen and watched it finish" from + * every other way a recording can end, so that the (Google Play build's) ads appear at + * that natural pause and nowhere else. Meaningless in the foss build. + */ + private var lastObservedState = RecordingStateHolder.current.state + private var stopRequestedInApp = false + private val folderPicker = registerForActivityResult( ActivityResultContracts.OpenDocumentTree() ) { uri: Uri? -> @@ -97,15 +111,20 @@ class MainActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // targetSdk 35+ draws edge to edge whether asked to or not; asking explicitly keeps + // the system bars styled correctly in light and dark themes. + enableEdgeToEdge() super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + applySystemBarInsets() settings = SettingsRepository(this) storage = VideoStorageManager(this, settings) cameraRepository = CameraRepository(this) + monetization = MonetizationController(this) - binding.rowFolder.setOnClickListener { folderPicker.launch(null) } + binding.rowFolder.setOnClickListener { launchFolderPicker() } binding.rowCamera.setOnClickListener { showCameraDialog() } binding.rowQuality.setOnClickListener { showQualityDialog() } binding.rowCompression.setOnClickListener { showCompressionDialog() } @@ -115,12 +134,14 @@ class MainActivity : AppCompatActivity() { binding.primaryButton.setOnClickListener { onPrimaryClick() } binding.rowLanguage.setOnClickListener { showLanguageDialog() } binding.rowAbout.setOnClickListener { + monetization.onLeavingForSystemScreen() startActivity(Intent(this, AboutActivity::class.java)) } handleSetupHint(intent) loadCameras() observeRecordingState() + monetization.attach(savedInstanceState) } override fun onNewIntent(intent: Intent) { @@ -135,6 +156,13 @@ class MainActivity : AppCompatActivity() { refreshRows() } + override fun onStop() { + // A recording that finishes while the app is in the background is not the + // "just pressed Stop" moment, so it must not trigger anything on return. + stopRequestedInApp = false + super.onStop() + } + /** The widget sends the user here when the app has not been fully configured yet. */ private fun handleSetupHint(intent: Intent?) { if (intent?.getBooleanExtra(EXTRA_SETUP_REQUIRED, false) == true) { @@ -148,7 +176,10 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { - RecordingStateHolder.status.collect { render(it) } + RecordingStateHolder.status.collect { + render(it) + trackStopForMonetization(it) + } } launch { // Keeps the elapsed-time readout moving while the screen is visible. @@ -161,6 +192,19 @@ class MainActivity : AppCompatActivity() { } } + private fun trackStopForMonetization(status: RecordingStatus) { + val previous = lastObservedState + lastObservedState = status.state + when { + status.state == RecordingState.STARTING -> stopRequestedInApp = false + stopRequestedInApp && status.state == RecordingState.IDLE && + (previous == RecordingState.RECORDING || previous == RecordingState.STOPPING) -> { + stopRequestedInApp = false + monetization.onRecordingStoppedByUser() + } + } + } + private fun render(status: RecordingStatus) { binding.statusText.text = statusTextFor(status) @@ -234,6 +278,7 @@ class MainActivity : AppCompatActivity() { when (RecordingStateHolder.current.state) { RecordingState.RECORDING -> { Log.i(TAG, "Stop requested from the app") + stopRequestedInApp = true RecordingService.send(this, RecordingService.ACTION_STOP) } RecordingState.STARTING, RecordingState.STOPPING -> Unit // button is disabled @@ -249,7 +294,7 @@ class MainActivity : AppCompatActivity() { if (!storage.hasUsableFolder()) { val error = storage.folderError() if (error != null) toast(getString(error.messageRes)) - folderPicker.launch(null) + launchFolderPicker() return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -395,6 +440,26 @@ class MainActivity : AppCompatActivity() { // ------------------------------------------------------------------ helpers + /** The picker is another app's screen; coming back from it is not "returning to the app". */ + private fun launchFolderPicker() { + monetization.onLeavingForSystemScreen() + folderPicker.launch(null) + } + + /** + * Keeps the content (and the banner under it) clear of the status bar, the navigation + * bar and any display cutout now that the window is drawn edge to edge. + */ + private fun applySystemBarInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding(bars.left, bars.top, bars.right, bars.bottom) + WindowInsetsCompat.CONSUMED + } + } + private fun loadCameras() { lifecycleScope.launch { cameras = try { diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index d9f4a4a..10b90a8 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -73,7 +73,7 @@ android:textColor="?attr/colorPrimary" /> + + + + + + + - + + + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7309b7e..884921d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -59,6 +59,7 @@ Исходный код Приложение с открытым исходным кодом. Сборка, опубликованная на F-Droid и GitHub, не содержит рекламы, платежей и вообще никакого доступа к сети. Связь + Политика конфиденциальности GitHub — Holego Jabber — Jopirat@jabber.fr Версия %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b371c26..761fb37 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ Source code This app is open source. The build published on F-Droid and GitHub contains no ads, no billing and no network access at all. Contact + Privacy policy GitHub — Holego Jabber — Jopirat@jabber.fr Version %1$s diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 0000000..a7de9d3 --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/play/java/io/github/holego/bcam/monetization/AdPolicy.kt b/app/src/play/java/io/github/holego/bcam/monetization/AdPolicy.kt new file mode 100644 index 0000000..af66125 --- /dev/null +++ b/app/src/play/java/io/github/holego/bcam/monetization/AdPolicy.kt @@ -0,0 +1,108 @@ +package io.github.holego.bcam.monetization + +/** + * The handful of facts the ad rules depend on. Backed by SharedPreferences in the app + * ([PrefsAdStore]) and by a plain object in the unit tests, which is the only reason this + * is an interface. + */ +interface AdStore { + /** Cached result of the last Play Billing check: the user bought "remove ads". */ + var adsRemoved: Boolean + + /** Wall-clock time (epoch millis) until which a watched reward keeps ads off. */ + var adFreeUntilMs: Long + + /** How many times the app has been started. */ + var launchCount: Int + + /** How many recordings the user has stopped from inside the app. */ + var stopCount: Int + + /** When a full-screen ad (interstitial, rewarded, app open) was last shown. */ + var lastFullScreenMs: Long +} + +/** What, if anything, to offer at the moment the user stops a recording. */ +enum class BreakAd { + NONE, + INTERSTITIAL, + + /** Rewarded interstitial: shown only after the user agrees to it in a dialog. */ + REWARDED_OFFER, +} + +/** + * When ads may appear. Deliberately conservative: ads only ever show at natural pauses in + * the setup app, never while recording and never when recording is started from the + * widget (which does not open the app at all). + * + * Pure logic with an injectable clock, so it is covered by plain JVM unit tests. + */ +class AdPolicy( + private val store: AdStore, + private val clock: () -> Long = System::currentTimeMillis, +) { + + /** True once "remove ads" has been bought (as last confirmed by Google Play). */ + val adsRemoved: Boolean get() = store.adsRemoved + + /** True when no ad of any kind may be loaded or shown right now. */ + fun adsSuppressed(): Boolean = store.adsRemoved || adFreeRemainingMs() > 0L + + /** Milliseconds of reward-based ad-free time left; 0 when none. */ + fun adFreeRemainingMs(): Long { + val remaining = store.adFreeUntilMs - clock() + // A reward is never longer than REWARD_AD_FREE_MS, so anything beyond that means + // the system clock was moved backwards after the grant. Treat it as expired. + return if (remaining in 1..REWARD_AD_FREE_MS) remaining else 0L + } + + /** Called when the user earns a reward. Restarts the window rather than stacking it. */ + fun grantAdFree(durationMs: Long = REWARD_AD_FREE_MS) { + store.adFreeUntilMs = clock() + durationMs + } + + fun setAdsRemoved(removed: Boolean) { + store.adsRemoved = removed + } + + fun onAppStarted() { + store.launchCount += 1 + } + + /** + * App-open ads are for *returning* to the app, not for the first impression: never on + * the first launch, and never right after another full-screen ad. + */ + fun canShowAppOpen(): Boolean = + !adsSuppressed() && store.launchCount >= 2 && cooldownElapsed() + + /** + * Called when the user stops a recording from the app. Decides what to show, if + * anything. Interstitials and reward offers alternate, and the very first recording + * the user ever makes is left alone. + */ + fun onUserStoppedRecording(): BreakAd { + store.stopCount += 1 + return when { + adsSuppressed() -> BreakAd.NONE + store.stopCount < 2 -> BreakAd.NONE + !cooldownElapsed() -> BreakAd.NONE + store.stopCount % 2 == 0 -> BreakAd.INTERSTITIAL + else -> BreakAd.REWARDED_OFFER + } + } + + /** Call when a full-screen ad has actually been put on screen. */ + fun recordFullScreenShown() { + store.lastFullScreenMs = clock() + } + + private fun cooldownElapsed(): Boolean = + clock() - store.lastFullScreenMs >= FULL_SCREEN_COOLDOWN_MS + + companion object { + const val FULL_SCREEN_COOLDOWN_MS = 3 * 60 * 1000L + const val REWARD_AD_FREE_MS = 24 * 60 * 60 * 1000L + } +} diff --git a/app/src/play/java/io/github/holego/bcam/monetization/AdUnits.kt b/app/src/play/java/io/github/holego/bcam/monetization/AdUnits.kt new file mode 100644 index 0000000..ef509ef --- /dev/null +++ b/app/src/play/java/io/github/holego/bcam/monetization/AdUnits.kt @@ -0,0 +1,40 @@ +package io.github.holego.bcam.monetization + +import io.github.holego.bcam.BuildConfig + +/** + * AdMob ad unit ids. + * + * Debug builds get Google's public sample units, which always fill and never produce real + * traffic. Release builds get the live units. Tapping your own live ads is an AdMob policy + * violation and can get the account suspended - on a personal device, add it as a test + * device in the AdMob console first. + * + * Ad unit ids are not secrets: every app ships them in plain text. + */ +internal object AdUnits { + + val banner: String = + if (BuildConfig.DEBUG) "ca-app-pub-3940256099942544/9214589741" + else "ca-app-pub-6234952902147314/7765454659" + + val interstitial: String = + if (BuildConfig.DEBUG) "ca-app-pub-3940256099942544/1033173712" + else "ca-app-pub-6234952902147314/1699748918" + + val rewardedInterstitial: String = + if (BuildConfig.DEBUG) "ca-app-pub-3940256099942544/5354046379" + else "ca-app-pub-6234952902147314/5602325743" + + val rewarded: String = + if (BuildConfig.DEBUG) "ca-app-pub-3940256099942544/5224354917" + else "ca-app-pub-6234952902147314/2106943022" + + val nativeAdvanced: String = + if (BuildConfig.DEBUG) "ca-app-pub-3940256099942544/2247696110" + else "ca-app-pub-6234952902147314/5906587234" + + val appOpen: String = + if (BuildConfig.DEBUG) "ca-app-pub-3940256099942544/9257395921" + else "ca-app-pub-6234952902147314/7894367035" +} diff --git a/app/src/play/java/io/github/holego/bcam/monetization/AdsManager.kt b/app/src/play/java/io/github/holego/bcam/monetization/AdsManager.kt new file mode 100644 index 0000000..0dc8d54 --- /dev/null +++ b/app/src/play/java/io/github/holego/bcam/monetization/AdsManager.kt @@ -0,0 +1,370 @@ +package io.github.holego.bcam.monetization + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.View +import android.widget.FrameLayout +import com.google.android.gms.ads.AdError +import com.google.android.gms.ads.AdListener +import com.google.android.gms.ads.AdLoader +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.AdSize +import com.google.android.gms.ads.AdView +import com.google.android.gms.ads.FullScreenContentCallback +import com.google.android.gms.ads.LoadAdError +import com.google.android.gms.ads.MobileAds +import com.google.android.gms.ads.appopen.AppOpenAd +import com.google.android.gms.ads.interstitial.InterstitialAd +import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback +import com.google.android.gms.ads.nativead.NativeAd +import com.google.android.gms.ads.nativead.NativeAdOptions +import com.google.android.gms.ads.rewarded.RewardedAd +import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback +import com.google.android.gms.ads.rewardedinterstitial.RewardedInterstitialAd +import com.google.android.gms.ads.rewardedinterstitial.RewardedInterstitialAdLoadCallback + +/** + * Loads and shows the six AdMob formats. It decides nothing about *whether* an ad may + * appear - that is [AdPolicy] and [MonetizationController]; this class only does what it + * is told and keeps the loaded ads around. + * + * Must be used from the main thread. Every ad is loaded ahead of time and replaced after + * it has been shown, so a "show" call never waits on the network: if nothing is ready it + * returns false and the moment simply passes without an ad. + */ +internal class AdsManager( + private val activity: Activity, + private val policy: AdPolicy, +) { + private val appContext: Context = activity.applicationContext + + private var banner: AdView? = null + private var interstitial: InterstitialAd? = null + private var rewardedInterstitial: RewardedInterstitialAd? = null + private var rewarded: RewardedAd? = null + private var appOpen: AppOpenAd? = null + private var appOpenLoadedAtMs = 0L + private var nativeAd: NativeAd? = null + + private var loadingInterstitial = false + private var loadingRewardedInterstitial = false + private var loadingRewarded = false + private var loadingAppOpen = false + private var loadingNative = false + + private var destroyed = false + + /** True while any full-screen ad is on screen. */ + var isShowingFullScreen = false + private set + + val hasRewardedInterstitial: Boolean get() = rewardedInterstitial != null + val hasRewarded: Boolean get() = rewarded != null + + // ------------------------------------------------------------------ loading + + /** + * Starts every format that is not already loaded. Safe to call repeatedly. The native ad + * stays owned by this class (and is destroyed with it); [onNativeReady] only binds it. + */ + fun preload(onNativeReady: (NativeAd) -> Unit) { + loadInterstitial() + loadRewardedInterstitial() + loadRewarded() + loadAppOpen() + loadNative(onNativeReady) + } + + private fun request(): AdRequest = AdRequest.Builder().build() + + private fun loadInterstitial() { + if (interstitial != null || loadingInterstitial || destroyed) return + loadingInterstitial = true + InterstitialAd.load( + appContext, + AdUnits.interstitial, + request(), + object : InterstitialAdLoadCallback() { + override fun onAdLoaded(ad: InterstitialAd) { + loadingInterstitial = false + interstitial = ad + } + + override fun onAdFailedToLoad(error: LoadAdError) { + loadingInterstitial = false + log("interstitial", error) + } + }, + ) + } + + private fun loadRewardedInterstitial() { + if (rewardedInterstitial != null || loadingRewardedInterstitial || destroyed) return + loadingRewardedInterstitial = true + RewardedInterstitialAd.load( + appContext, + AdUnits.rewardedInterstitial, + request(), + object : RewardedInterstitialAdLoadCallback() { + override fun onAdLoaded(ad: RewardedInterstitialAd) { + loadingRewardedInterstitial = false + rewardedInterstitial = ad + } + + override fun onAdFailedToLoad(error: LoadAdError) { + loadingRewardedInterstitial = false + log("rewarded interstitial", error) + } + }, + ) + } + + private fun loadRewarded() { + if (rewarded != null || loadingRewarded || destroyed) return + loadingRewarded = true + RewardedAd.load( + appContext, + AdUnits.rewarded, + request(), + object : RewardedAdLoadCallback() { + override fun onAdLoaded(ad: RewardedAd) { + loadingRewarded = false + rewarded = ad + } + + override fun onAdFailedToLoad(error: LoadAdError) { + loadingRewarded = false + log("rewarded", error) + } + }, + ) + } + + private fun loadAppOpen() { + if (appOpen != null || loadingAppOpen || destroyed) return + loadingAppOpen = true + AppOpenAd.load( + appContext, + AdUnits.appOpen, + request(), + object : AppOpenAd.AppOpenAdLoadCallback() { + override fun onAdLoaded(ad: AppOpenAd) { + loadingAppOpen = false + appOpen = ad + appOpenLoadedAtMs = SystemClock.elapsedRealtime() + } + + override fun onAdFailedToLoad(error: LoadAdError) { + loadingAppOpen = false + log("app open", error) + } + }, + ) + } + + private fun loadNative(onReady: (NativeAd) -> Unit) { + if (nativeAd != null || loadingNative || destroyed) return + loadingNative = true + AdLoader.Builder(appContext, AdUnits.nativeAdvanced) + .forNativeAd { ad -> + loadingNative = false + if (destroyed) { + ad.destroy() + } else { + nativeAd = ad + onReady(ad) + } + } + .withAdListener(object : AdListener() { + override fun onAdFailedToLoad(error: LoadAdError) { + loadingNative = false + log("native", error) + } + }) + .withNativeAdOptions(NativeAdOptions.Builder().build()) + .build() + .loadAd(request()) + } + + // ------------------------------------------------------------------ banner + + /** Adds an adaptive banner to [container]; the container stays hidden until it loads. */ + fun showBanner(container: FrameLayout) { + if (banner != null || destroyed) return + val metrics = activity.resources.displayMetrics + val widthPx = if (container.width > 0) container.width else metrics.widthPixels + val widthDp = (widthPx / metrics.density).toInt() + + val view = AdView(activity) + view.adUnitId = AdUnits.banner + view.setAdSize(AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(activity, widthDp)) + view.adListener = object : AdListener() { + override fun onAdLoaded() { + container.visibility = View.VISIBLE + } + + override fun onAdFailedToLoad(error: LoadAdError) { + container.visibility = View.GONE + log("banner", error) + } + } + container.removeAllViews() + container.addView(view) + banner = view + view.loadAd(request()) + } + + fun hideBanner(container: FrameLayout) { + banner?.destroy() + banner = null + container.removeAllViews() + container.visibility = View.GONE + } + + fun pauseBanner() { + banner?.pause() + } + + fun resumeBanner() { + banner?.resume() + } + + // ------------------------------------------------------------------ native + + // ------------------------------------------------------------------ showing + + fun showInterstitial(): Boolean { + val ad = interstitial + if (ad == null) { + loadInterstitial() + return false + } + interstitial = null + ad.fullScreenContentCallback = fullScreenCallback { loadInterstitial() } + ad.show(activity) + return true + } + + /** [onReward] runs when the user has watched enough of the video to earn the reward. */ + fun showRewardedInterstitial(onReward: () -> Unit): Boolean { + val ad = rewardedInterstitial + if (ad == null) { + loadRewardedInterstitial() + return false + } + rewardedInterstitial = null + ad.fullScreenContentCallback = fullScreenCallback { loadRewardedInterstitial() } + ad.show(activity) { onReward() } + return true + } + + fun showRewarded(onReward: () -> Unit): Boolean { + val ad = rewarded + if (ad == null) { + loadRewarded() + return false + } + rewarded = null + ad.fullScreenContentCallback = fullScreenCallback { loadRewarded() } + ad.show(activity) { onReward() } + return true + } + + /** App-open ads expire four hours after loading; an expired one is dropped, not shown. */ + fun showAppOpen(): Boolean { + val ad = appOpen + if (ad == null) { + loadAppOpen() + return false + } + appOpen = null + if (SystemClock.elapsedRealtime() - appOpenLoadedAtMs > APP_OPEN_MAX_AGE_MS) { + loadAppOpen() + return false + } + ad.fullScreenContentCallback = fullScreenCallback { loadAppOpen() } + ad.show(activity) + return true + } + + private fun fullScreenCallback(afterwards: () -> Unit) = object : FullScreenContentCallback() { + override fun onAdShowedFullScreenContent() { + isShowingFullScreen = true + policy.recordFullScreenShown() + } + + override fun onAdDismissedFullScreenContent() { + isShowingFullScreen = false + afterwards() + } + + override fun onAdFailedToShowFullScreenContent(error: AdError) { + isShowingFullScreen = false + Log.w(TAG, "Ad failed to show: " + error.code + " " + error.message) + afterwards() + } + } + + // ------------------------------------------------------------------ lifecycle + + fun destroy() { + destroyed = true + banner?.destroy() + banner = null + nativeAd?.destroy() + nativeAd = null + interstitial = null + rewardedInterstitial = null + rewarded = null + appOpen = null + } + + private fun log(format: String, error: LoadAdError) { + Log.w(TAG, "Could not load " + format + " ad: " + error.code + " " + error.message) + } + + companion object { + private const val TAG = "Monetization" + private const val APP_OPEN_MAX_AGE_MS = 4 * 60 * 60 * 1000L + + private val main = Handler(Looper.getMainLooper()) + private val waiting = mutableListOf<() -> Unit>() + private var sdkState = SDK_IDLE + + private const val SDK_IDLE = 0 + private const val SDK_STARTING = 1 + private const val SDK_READY = 2 + + /** + * Initialises the Mobile Ads SDK once per process, off the main thread as Google + * recommends, and runs [onReady] on the main thread when it is done. Call this only + * after consent has been gathered and only when ads are actually allowed - a user + * who has bought "remove ads" never causes the SDK to start. + */ + fun ensureSdk(context: Context, onReady: () -> Unit) { + when (sdkState) { + SDK_READY -> onReady() + SDK_STARTING -> waiting += onReady + else -> { + sdkState = SDK_STARTING + waiting += onReady + val appContext = context.applicationContext + Thread { + MobileAds.initialize(appContext) { + main.post { + sdkState = SDK_READY + val callbacks = waiting.toList() + waiting.clear() + callbacks.forEach { it() } + } + } + }.start() + } + } + } + } +} diff --git a/app/src/play/java/io/github/holego/bcam/monetization/BillingManager.kt b/app/src/play/java/io/github/holego/bcam/monetization/BillingManager.kt new file mode 100644 index 0000000..438acd6 --- /dev/null +++ b/app/src/play/java/io/github/holego/bcam/monetization/BillingManager.kt @@ -0,0 +1,185 @@ +package io.github.holego.bcam.monetization + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import io.github.holego.bcam.R + +/** + * The one-time "remove ads" purchase, through Google Play Billing. + * + * Product: a *non-consumable* in-app product with id [PRODUCT_ID], priced at US$9.00 in + * the Play Console. The price shown in the app is whatever Play reports for the user's + * country, so it is never hard-coded here. + * + * Entitlement is re-checked with Google Play on every start ([queryOwned]); that is also + * how a purchase is restored after a reinstall or on a new device, and how a refund takes + * effect. If Play cannot be reached the last known answer is kept. + * + * There is no backend, so purchases are not verified server-side. For a US$9 unlock in a + * small app that is an accepted trade-off; it does mean a rooted device can fake the + * entitlement locally. + */ +internal class BillingManager( + context: Context, + /** `true`/`false` once Play has answered; `null` when it could not be reached. */ + private val onEntitlement: (owned: Boolean?) -> Unit, + private val onProductReady: () -> Unit, + private val onMessage: (messageRes: Int) -> Unit, +) : PurchasesUpdatedListener { + + private val client: BillingClient = BillingClient.newBuilder(context.applicationContext) + .setListener(this) + .enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() + ) + .enableAutoServiceReconnection() + .build() + + private var productDetails: ProductDetails? = null + + /** Localised price such as "$9.00", or null until Play has described the product. */ + val formattedPrice: String? + get() = productDetails?.oneTimePurchaseOfferDetails?.formattedPrice + + fun start() { + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queryProduct() + queryOwned() + } else { + Log.w(TAG, "Billing setup failed: " + result.responseCode + " " + result.debugMessage) + onEntitlement(null) + } + } + + override fun onBillingServiceDisconnected() { + // enableAutoServiceReconnection() reconnects on the next call. + Log.i(TAG, "Billing service disconnected") + } + }) + } + + fun end() { + client.endConnection() + } + + /** Opens Google Play's purchase sheet. Returns false if the product is not available. */ + fun launchPurchase(activity: Activity): Boolean { + val details = productDetails ?: return false + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(details) + .build() + ) + ) + .build() + val result = client.launchBillingFlow(activity, params) + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.w(TAG, "launchBillingFlow: " + result.responseCode + " " + result.debugMessage) + } + return result.responseCode == BillingClient.BillingResponseCode.OK + } + + override fun onPurchasesUpdated(result: BillingResult, purchases: MutableList?) { + when (result.responseCode) { + BillingClient.BillingResponseCode.OK -> purchases?.forEach(::handlePurchase) + BillingClient.BillingResponseCode.USER_CANCELED -> Unit + // Bought earlier (reinstall, other device): just re-read what Play knows. + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> queryOwned() + else -> { + Log.w(TAG, "Purchase failed: " + result.responseCode + " " + result.debugMessage) + onMessage(R.string.purchase_failed) + } + } + } + + private fun queryProduct() { + val product = QueryProductDetailsParams.Product.newBuilder() + .setProductId(PRODUCT_ID) + .setProductType(BillingClient.ProductType.INAPP) + .build() + val params = QueryProductDetailsParams.newBuilder() + .setProductList(listOf(product)) + .build() + client.queryProductDetailsAsync(params) { result, queryResult -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + productDetails = queryResult.productDetailsList.firstOrNull() + if (productDetails == null) { + Log.w(TAG, "Product $PRODUCT_ID is not configured in the Play Console") + } + } else { + Log.w(TAG, "queryProductDetails: " + result.responseCode + " " + result.debugMessage) + } + onProductReady() + } + } + + private fun queryOwned() { + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.INAPP) + .build() + client.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.w(TAG, "queryPurchases: " + result.responseCode + " " + result.debugMessage) + onEntitlement(null) + return@queryPurchasesAsync + } + val owned = purchases.filter { ownsRemoveAds(it) } + owned.forEach(::acknowledgeIfNeeded) + onEntitlement(owned.isNotEmpty()) + } + } + + private fun handlePurchase(purchase: Purchase) { + if (!purchase.products.contains(PRODUCT_ID)) return + when (purchase.purchaseState) { + Purchase.PurchaseState.PURCHASED -> { + acknowledgeIfNeeded(purchase) + onEntitlement(true) + onMessage(R.string.purchase_thanks) + } + // Slow payment methods (cash, bank transfer). Ads go off once it completes. + Purchase.PurchaseState.PENDING -> onMessage(R.string.purchase_pending) + else -> Unit + } + } + + private fun ownsRemoveAds(purchase: Purchase): Boolean = + purchase.products.contains(PRODUCT_ID) && + purchase.purchaseState == Purchase.PurchaseState.PURCHASED + + /** + * Play refunds a purchase that is not acknowledged within three days. If this call + * fails (offline), [queryOwned] retries it on the next start. + */ + private fun acknowledgeIfNeeded(purchase: Purchase) { + if (purchase.isAcknowledged) return + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + client.acknowledgePurchase(params) { result -> + Log.i(TAG, "acknowledgePurchase: " + result.responseCode) + } + } + + companion object { + /** Must match the product id created in Play Console > Monetize > In-app products. */ + const val PRODUCT_ID = "remove_ads" + private const val TAG = "Monetization" + } +} diff --git a/app/src/play/java/io/github/holego/bcam/monetization/ConsentManager.kt b/app/src/play/java/io/github/holego/bcam/monetization/ConsentManager.kt new file mode 100644 index 0000000..a157835 --- /dev/null +++ b/app/src/play/java/io/github/holego/bcam/monetization/ConsentManager.kt @@ -0,0 +1,64 @@ +package io.github.holego.bcam.monetization + +import android.app.Activity +import android.util.Log +import com.google.android.ump.ConsentInformation +import com.google.android.ump.ConsentRequestParameters +import com.google.android.ump.UserMessagingPlatform + +/** + * Google's User Messaging Platform: the consent form that is mandatory for ads shown to + * users in the EEA, the UK and Switzerland, and the "privacy options" entry point that has + * to stay reachable afterwards so the user can change their mind. + * + * The form's text and the regions it applies to are configured in the AdMob console + * (Privacy & messaging), not here. Until a message is published there, UMP reports that + * no form is required. + */ +internal class ConsentManager(private val activity: Activity) { + + private val info: ConsentInformation = UserMessagingPlatform.getConsentInformation(activity) + + val privacyOptionsRequired: Boolean + get() = info.privacyOptionsRequirementStatus == + ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED + + /** + * Refreshes the consent state and shows the form if one is needed. Called on every + * launch, as Google asks. [onDone] receives whether ads may be requested. + */ + fun gather(onDone: (canRequestAds: Boolean) -> Unit) { + val params = ConsentRequestParameters.Builder().build() + info.requestConsentInfoUpdate( + activity, + params, + { + if (activity.isFinishing || activity.isDestroyed) return@requestConsentInfoUpdate + UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity) { formError -> + if (formError != null) { + Log.w(TAG, "Consent form: " + formError.errorCode + " " + formError.message) + } + onDone(info.canRequestAds()) + } + }, + { formError -> + Log.w(TAG, "Consent info update failed: " + formError.errorCode + " " + formError.message) + // A previous session may already have collected consent. + onDone(info.canRequestAds()) + }, + ) + } + + fun showPrivacyOptions(onDismissed: () -> Unit) { + UserMessagingPlatform.showPrivacyOptionsForm(activity) { formError -> + if (formError != null) { + Log.w(TAG, "Privacy options form: " + formError.errorCode + " " + formError.message) + } + onDismissed() + } + } + + private companion object { + const val TAG = "Monetization" + } +} diff --git a/app/src/play/java/io/github/holego/bcam/monetization/MonetizationController.kt b/app/src/play/java/io/github/holego/bcam/monetization/MonetizationController.kt new file mode 100644 index 0000000..d7c23d6 --- /dev/null +++ b/app/src/play/java/io/github/holego/bcam/monetization/MonetizationController.kt @@ -0,0 +1,320 @@ +package io.github.holego.bcam.monetization + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.android.gms.ads.nativead.MediaView +import com.google.android.gms.ads.nativead.NativeAd +import com.google.android.gms.ads.nativead.NativeAdView +import io.github.holego.bcam.R + +/** + * Everything ad- and purchase-related on the main screen, behind four calls from + * MainActivity. The foss flavor has a class with the same name whose methods do nothing. + * + * Where ads appear - and where they never do: + * - banner: bottom of the setup screen; native card: below the settings; + * - interstitial / reward offer: right after the user stops a recording *in the app*; + * - app-open: when the user comes back to the app after a while away; + * - rewarded: only when the user taps "watch a video". + * Never while recording, never from the widget or notification, and nothing is shown at all + * once "remove ads" is bought or a reward is active. + */ +internal class MonetizationController(private val activity: AppCompatActivity) : + DefaultLifecycleObserver { + + private val policy = AdPolicy(PrefsAdStore(activity)) + private val ads = AdsManager(activity, policy) + private val consent = ConsentManager(activity) + private val billing = BillingManager( + activity, + onEntitlement = ::onEntitlement, + onProductReady = ::render, + onMessage = ::toast, + ) + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var bannerContainer: FrameLayout + private lateinit var nativeContainer: FrameLayout + private lateinit var section: View + + private var entitlementKnown = false + private var consentInFlight = false + private var consentAttempted = false + private var adsStarted = false + private var suppressNextAppOpen = false + private var destroyed = false + + // ------------------------------------------------------------------ MainActivity API + + /** Call once from onCreate, after the layout is inflated. */ + fun attach(savedInstanceState: Bundle?) { + // Not on rotation / recreation: those are not new launches. + if (savedInstanceState == null) policy.onAppStarted() + + bannerContainer = activity.findViewById(R.id.ad_banner_container) + nativeContainer = activity.findViewById(R.id.ad_native_container) + val sectionContainer = activity.findViewById(R.id.monetization_container) + section = LayoutInflater.from(activity) + .inflate(R.layout.section_monetization, sectionContainer, false) + sectionContainer.addView(section) + sectionContainer.visibility = View.VISIBLE + + section.findViewById