From cc8684a828ea1c2f75cffeb241cb344008f63a04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:00:02 +0000 Subject: [PATCH 1/7] Remove the release keystore password from .gitignore and ignore key material The password for the release keystore had been pasted into .gitignore by mistake (commit dd34700). The file is dropped from the ignore list here; the old value stays in history, so the keystore password should be changed. *.jks, *.keystore, *.p12 and *.aab are now ignored so signing material cannot be committed by accident. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_011ByB3B7q6Y3c7a3SkYuRnH --- .gitignore | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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/ From facc09842a52c19c1e6e54c2fb50de643354b04f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:00:02 +0000 Subject: [PATCH 2/7] Add a Google Play flavor with AdMob ads and a one-time remove-ads purchase Two flavors now: foss (F-Droid/GitHub, unchanged: no ads, no billing, no INTERNET) and play (AdMob banner, interstitial, rewarded, rewarded interstitial, native and app-open ads, UMP consent, Play Billing product remove_ads). Ads show only at natural pauses in the setup app, never while recording and never from the widget. Debug builds use Google's sample ad units so development never produces real ad traffic. Also raises the toolchain to what Play now requires: targetSdk/compileSdk 36 (needed from 31 Aug 2026), AGP 8.13, Gradle 8.13, Kotlin 2.2, Billing 9. Handles edge-to-edge insets, keeps all languages in the bundle for the in-app language switch, and adds CI that builds both flavors, checks the foss APK stays network-free, and a manual workflow that builds the signed AAB from repository secrets. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_011ByB3B7q6Y3c7a3SkYuRnH --- .github/workflows/build.yml | 76 ++++ .github/workflows/release-aab.yml | 76 ++++ app/build.gradle.kts | 82 +++- .../monetization/MonetizationController.kt | 19 + .../io/github/holego/bcam/AboutActivity.kt | 16 + .../io/github/holego/bcam/MainActivity.kt | 71 +++- app/src/main/res/layout/activity_about.xml | 28 +- app/src/main/res/layout/activity_main.xml | 39 +- app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/play/AndroidManifest.xml | 28 ++ .../holego/bcam/monetization/AdPolicy.kt | 108 +++++ .../holego/bcam/monetization/AdUnits.kt | 40 ++ .../holego/bcam/monetization/AdsManager.kt | 370 ++++++++++++++++++ .../bcam/monetization/BillingManager.kt | 185 +++++++++ .../bcam/monetization/ConsentManager.kt | 64 +++ .../monetization/MonetizationController.kt | 320 +++++++++++++++ .../holego/bcam/monetization/PrefsAdStore.kt | 45 +++ app/src/play/res/drawable/bg_ad_badge.xml | 8 + app/src/play/res/drawable/bg_ad_card.xml | 9 + app/src/play/res/layout/ad_native.xml | 92 +++++ .../play/res/layout/section_monetization.xml | 67 ++++ app/src/play/res/values-ru/strings.xml | 31 ++ app/src/play/res/values/strings.xml | 35 ++ .../holego/bcam/monetization/AdPolicyTest.kt | 124 ++++++ build.gradle.kts | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 27 files changed, 1926 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release-aab.yml create mode 100644 app/src/foss/java/io/github/holego/bcam/monetization/MonetizationController.kt create mode 100644 app/src/play/AndroidManifest.xml create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/AdPolicy.kt create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/AdUnits.kt create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/AdsManager.kt create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/BillingManager.kt create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/ConsentManager.kt create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/MonetizationController.kt create mode 100644 app/src/play/java/io/github/holego/bcam/monetization/PrefsAdStore.kt create mode 100644 app/src/play/res/drawable/bg_ad_badge.xml create mode 100644 app/src/play/res/drawable/bg_ad_card.xml create mode 100644 app/src/play/res/layout/ad_native.xml create mode 100644 app/src/play/res/layout/section_monetization.xml create mode 100644 app/src/play/res/values-ru/strings.xml create mode 100644 app/src/play/res/values/strings.xml create mode 100644 app/src/testPlay/java/io/github/holego/bcam/monetization/AdPolicyTest.kt 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/app/build.gradle.kts b/app/build.gradle.kts index b0602ed..c8b07b2 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,14 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } @@ -123,4 +176,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