diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 577e5dd..b0bc75b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,24 +22,40 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: dummy + - name: Configure Play test ad IDs run: | echo appId=ca-app-pub-3940256099942544~3347511713 > ads.properties echo unitId=ca-app-pub-3940256099942544/6300978111 >> ads.properties - name: ktlint run: ./gradlew ktlintCheck - - name: Generate kover coverage report - run: ./gradlew koverXmlReportDebug + - name: Unit tests + run: ./gradlew testPlayDebugUnitTest testFossDebugUnitTest + - name: Generate Kover coverage reports + run: ./gradlew koverXmlReportPlayDebug koverXmlReportFossDebug - name: Android lint - run: ./gradlew lintRelease - - name: Build release bundle - run: ./gradlew bundleRelease - - name: Add coverage report to PR - id: kover + run: ./gradlew -PallowTestAds=true lintPlayRelease lintFossRelease + - name: Compile instrumentation test APKs + run: ./gradlew assemblePlayDebugAndroidTest assembleFossDebugAndroidTest + - name: Build Play release bundle + run: ./gradlew -PallowTestAds=true bundlePlayRelease + - name: Build FOSS release APK + run: ./gradlew assembleFossRelease + - name: Add Play coverage report to PR + id: kover_play uses: mi-kas/kover-report@v1 with: - path: | - ${{ github.workspace }}/app/build/reports/kover/reportDebug.xml - title: Code Coverage + path: ${{ github.workspace }}/app/build/reports/kover/reportPlayDebug.xml + title: Play Code Coverage update-comment: true coverage-counter-type: LINE + - name: Add FOSS coverage report to PR + id: kover_foss + uses: mi-kas/kover-report@v1 + with: + path: ${{ github.workspace }}/app/build/reports/kover/reportFossDebug.xml + title: FOSS Code Coverage + update-comment: true + coverage-counter-type: LINE + - name: Remove temporary ad properties + if: always() + run: rm -f ads.properties diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index e096511..9a4c4eb 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -22,7 +22,7 @@ This document provides an overview of all Gradle dependencies used in the Aplin - **Kover** (0.9.9) - Code coverage tool for Kotlin projects ### License Management -- **OSS Licenses Plugin** (0.10.6) - Generates open source license reports for compliance +- **Cash App Licensee Gradle plugin** (1.14.1) - Validates dependency licenses and bundles a reproducible build-time `artifacts.json` report; no Licensee runtime library is shipped ## Core Libraries @@ -49,16 +49,16 @@ This document provides an overview of all Gradle dependencies used in the Aplin ### Data & Preferences - **DataStore Preferences** (1.2.1) - Modern replacement for SharedPreferences with type safety and coroutine support -- **Preference KTX** (1.2.1) - Kotlin extensions for Android preferences ### Utilities - **Kotlin Reflect** (2.3.21) - Kotlin reflection library for runtime introspection - **Logcat** (0.4) - Structured logging library for Android -### Google Play Services -- **Play Services Ads** (23.5.0) - Google Mobile Ads SDK for displaying advertisements -- **Play Services OSS Licenses** (17.1.0) - Library for displaying open source licenses -- **User Messaging Platform (UMP)** (3.1.0) - Google's solution for GDPR and privacy compliance +### Distribution-specific libraries +- **Play only: Google Mobile Ads SDK** (25.4.0) - AdMob banner ads; isolated to `playImplementation` +- **Play only: User Messaging Platform** (4.0.0) - Official consent and privacy-options flow; isolated to `playImplementation` +- **FOSS only: none** - No Google Play Services, AdMob, UMP, or automatic network runtime dependency +- **Kotlinx Serialization JSON** (1.9.0) - Parses the offline Licensee catalog in the common license screen ## Testing Libraries @@ -84,7 +84,8 @@ Aplin is an Android application manager that helps users view and manage install - **Navigation** handles moving between different app screens - **Koin** manages dependency injection for clean architecture - **DataStore/Preferences** store user settings and app state -- **Play Services** handle advertisements and license compliance +- **Play distribution libraries** handle AdMob advertising and Google's UMP consent flow +- **Licensee** generates the offline dependency-license catalog at build time for both variants - **Testing libraries** ensure code quality and reliability - **Logcat** provides structured logging for debugging @@ -119,6 +120,15 @@ The update from Koin 3.5.6 to 4.0.0 is a major version change that may require c - Latest Compose UI updates may include new features and optimizations - Verify all Compose components render correctly +## Distribution matrix (2026-08-29) + +- `play` keeps the existing Play application ID `com.nagopy.android.aplin`; `foss` adds the `.foss` application ID suffix. +- Both variants use compile/target SDK 36, min SDK 26, versionCode 46, and versionName 5.6.0. +- Both variants keep `QUERY_ALL_PACKAGES` because package classification is Aplin's core on-device feature. +- Only Play has AdMob/UMP dependencies, AdMob manifest metadata, and INTERNET/ACCESS_NETWORK_STATE. The Google Mobile Ads SDK may contribute AD_ID to the Play merged manifest; FOSS removes it defensively and contains no matching runtime classes. +- FOSS has no ads, UMP, automatic communication, or INTERNET/ACCESS_NETWORK_STATE/AD_ID permission. Web lookup is only a user-initiated `ACTION_WEB_SEARCH` intent handled by another app. +- Both variants display the dependency catalog offline from Licensee's generated `assets/app/cash/licensee/artifacts.json`. Google OSS Licenses plugin/library has been removed. + ## Common Dependency Update (2026-08-29) - AndroidX stable versions were checked against the Android Developers stable release table and Google Maven metadata. Compose UI, Material, and Tooling are kept on the same `1.11.4` line, which is compatible with the unchanged compileSdk 36 and AGP 8.13.2. Material Icons Core remains on its separately published latest stable `1.7.8` artifact line. @@ -126,7 +136,10 @@ The update from Koin 3.5.6 to 4.0.0 is a major version change that may require c - AppCompat was updated from `1.7.0` to `1.7.1` and remains a direct dependency because `themes.xml` directly uses `Theme.AppCompat.Light.NoActionBar`; the four preview calls continue to use `AppCompatResources.getDrawable`. - Lifecycle `2.11.0` was not adopted because atomic alignment of `lifecycle-runtime-compose` and `lifecycle-viewmodel-compose` requires compileSdk 37 and AGP 9.1; `2.10.0` is the compatible stable line for this unchanged build setup. - No direct Material Components dependency is declared or resolved in the release runtime graph. -- `play-services-ads`, `user-messaging-platform`, `play-services-oss-licenses`, the OSS Licenses Gradle plugin, and `preference-ktx` were intentionally left unchanged for the flavor-separation work. +- Google Mobile Ads SDK 25.4.0 and UMP 4.0.0 are Play-only. The official UMP flow requests consent information at every launch, loads and shows required forms, checks `canRequestAds()`, exposes privacy options only when required, and does not read cached TCF values in application code. +- Cash App Licensee 1.14.1 is build-time only. Its generated report is bundled at the documented `assets/app/cash/licensee/artifacts.json` path and is parsed by the app without a Licensee runtime dependency. +- The in-app catalog displays coordinates and every license name, identifier, and URL present in that generated metadata. It is not a substitute for full NOTICE/copyright texts; F-Droid metadata and asset/notice licensing remain separate release work. +- Managed-emulator CI, reproducible-build proof, full third-party NOTICE/copyright text inclusion, and release signing remain outside this distribution change. ## Post-Update Verification Checklist diff --git a/README.md b/README.md index 31bcaf7..a8e2213 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,31 @@ Aplin is an Android application manager that provides a comprehensive list of in Simplify your device management with Aplin! ## Download -Aplin is available on Google Play. Download it [here](https://play.google.com/store/apps/details?id=com.nagopy.android.aplin). +Aplin has two distribution variants: + +| Variant | Application ID | Intended channel | Ads and privacy messaging | +| --- | --- | --- | --- | +| `play` | `com.nagopy.android.aplin` | Google Play, Japan | AdMob and Google's UMP | +| `foss` | `com.nagopy.android.aplin.foss` | F-Droid, worldwide | No ads, UMP, Google Play Services runtime, or network permissions | + +The Play release is available [on Google Play](https://play.google.com/store/apps/details?id=com.nagopy.android.aplin). F-Droid metadata and asset licensing submissions are separate release work. + +## Build + +```sh +./gradlew testPlayDebugUnitTest testFossDebugUnitTest +./gradlew lintPlayRelease lintFossRelease +./gradlew bundlePlayRelease +./gradlew assembleFossRelease +``` + +Play release requires a local, untracked `ads.properties` containing a correctly formatted real `appId` and `unitId`. Missing, placeholder, malformed, or known Google test IDs fail release validation. For non-publishable local/CI checks only, pass `-PallowTestAds=true`; this adds the `.ci` suffix, so `bundlePlayRelease` uses `com.nagopy.android.aplin.ci` and cannot be confused with the normal `com.nagopy.android.aplin` release. Play debug uses Google's official test IDs. FOSS builds do not read this file. `bundlePlayRelease` is unsigned unless a signing configuration is supplied separately. + +The open source license screen is generated from Cash App Licensee's build-time report and reads the bundled `assets/app/cash/licensee/artifacts.json` file offline. It displays dependency coordinates and the license metadata present in that generated list, not complete NOTICE/copyright texts. Licensee itself is not an application runtime dependency. F-Droid metadata and asset/notice licensing remain separate release work. + +FOSS uses `QUERY_ALL_PACKAGES` only to classify packages already installed on the device. It does not use that permission for network access or data collection. + +Managed-emulator CI, reproducible-build proof, full third-party NOTICE/copyright text inclusion, and release signing are outside this distribution split and remain separate release work. ## License Aplin's source code is licensed under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eaabf91..f6db9fc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.gradle.api.ApplicationVariant import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties @@ -6,11 +7,13 @@ plugins { alias(libs.plugins.androidApp) alias(libs.plugins.kotlinAndroid) alias(libs.plugins.kotlinPluginCompose) - id("com.google.android.gms.oss-licenses-plugin") + id("app.cash.licensee") alias(libs.plugins.ktlint) alias(libs.plugins.kover) } +val allowTestAds = project.providers.gradleProperty("allowTestAds").orNull == "true" + android { compileSdk = libs.versions.compileSdk @@ -35,19 +38,29 @@ android { vectorDrawables.useSupportLibrary = true } + flavorDimensions += "distribution" + productFlavors { + create("play") { + dimension = "distribution" + applicationId = "com.nagopy.android.aplin" + if (allowTestAds) { + applicationIdSuffix = ".ci" + } + } + create("foss") { + dimension = "distribution" + applicationIdSuffix = ".foss" + } + } + buildTypes { getByName("release") { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - - val adsProperties = readProperties(File("ads.properties")) - buildConfigField("String", "AD_UNIT_ID", "\"${adsProperties["unitId"]}\"") - resValue("string", "ad_app_id", "\"${adsProperties["appId"]}\"") // signingConfig = signingConfigs.getByName("debug") } getByName("debug") { - resValue("string", "ad_app_id", "ca-app-pub-3940256099942544~3347511713") - buildConfigField("String", "AD_UNIT_ID", "\"ca-app-pub-3940256099942544/6300978111\"") + applicationIdSuffix = ".debug" } } compileOptions { @@ -58,13 +71,65 @@ android { compose = true buildConfig = true } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - excludes += "META-INF/LICENSE.md" - excludes += "META-INF/LICENSE-notice.md" +} + +android.applicationVariants.all { + configurePlayAds(this) +} + +val validatePlayReleaseAds = + tasks.register("validatePlayReleaseAds") { + doLast { + val adsPropertiesFile = project.rootProject.file("ads.properties") + check(adsPropertiesFile.isFile) { + "Play release requires ${adsPropertiesFile.path} with appId and unitId; refusing to use test IDs." + } + val adsProperties = readProperties(adsPropertiesFile) + val appId = adsProperties.requireValue("appId") + val unitId = adsProperties.requireValue("unitId") + val allowTestAds = project.providers.gradleProperty("allowTestAds").orNull == "true" + + validateAdMobId( + name = "appId", + value = appId, + pattern = admobAppIdPattern, + ) + validateAdMobId( + name = "unitId", + value = unitId, + pattern = admobUnitIdPattern, + ) + if (!allowTestAds) { + check(appId !in googleTestAppIds && unitId !in googleTestUnitIds) { + "Google test ad IDs are not allowed for Play release; use real IDs or explicitly pass -PallowTestAds=true." + } + } } } + +tasks.configureEach { + val isPlayReleaseLifecycleTask = + name != validatePlayReleaseAds.name && + ( + name == "prePlayReleaseBuild" || + ( + name.contains("PlayRelease") && + setOf("assemble", "bundle", "package", "lint").any(name::startsWith) + ) + ) + if (isPlayReleaseLifecycleTask) { + dependsOn(validatePlayReleaseAds) + } +} + +licensee { + allow("Apache-2.0") + allow("BSD-2-Clause") + allow("BSD-3-Clause") + allow("MIT") + allow("ISC") + allowUrl("https://developer.android.com/studio/terms.html") + bundleAndroidAsset = true } kotlin { @@ -94,21 +159,72 @@ dependencies { implementation(libs.navigationCompose) - implementation(libs.playServicesOssLicenses) implementation(libs.appcompat) - implementation(libs.playServicesAds) - - implementation(libs.ump) - implementation(libs.preferenceKtx) + add("playImplementation", libs.playServicesAds) + add("playImplementation", libs.ump) testImplementation(libs.mockk) androidTestImplementation(libs.mockkAndroid) androidTestImplementation(libs.mockkAgent) implementation(libs.datastorePreferences) + implementation(libs.kotlinxSerializationJson) +} + +fun configurePlayAds(variant: ApplicationVariant) { + if (variant.flavorName != "play") { + return + } + + val (appId, unitId) = + if (variant.buildType.name == "debug") { + "ca-app-pub-3940256099942544~3347511713" to "ca-app-pub-3940256099942544/6300978111" + } else { + val adsPropertiesFile = project.rootProject.file("ads.properties") + if (adsPropertiesFile.isFile) { + val adsProperties = readProperties(adsPropertiesFile) + adsProperties.valueOrMissing("appId") to adsProperties.valueOrMissing("unitId") + } else { + "__MISSING_ADS_PROPERTIES__" to "__MISSING_ADS_PROPERTIES__" + } + } + + variant.buildConfigField("String", "AD_UNIT_ID", "\"$unitId\"") + variant.resValue("string", "ad_app_id", appId) } +fun Properties.requireValue(name: String): String = + getProperty(name)?.trim()?.takeIf { it.isNotEmpty() } + ?: error("ads.properties is missing non-empty $name for Play release") + +fun Properties.valueOrMissing(name: String): String = + getProperty(name) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: "__MISSING_ADS_PROPERTIES__" + +fun validateAdMobId( + name: String, + value: String, + pattern: Regex, +) { + check(value != "__MISSING_ADS_PROPERTIES__") { + "ads.properties contains a placeholder for $name" + } + check(!value.contains("placeholder", ignoreCase = true)) { + "ads.properties contains a placeholder for $name" + } + check(pattern.matches(value)) { + "ads.properties $name must match an AdMob ID format" + } +} + +val admobAppIdPattern = Regex("ca-app-pub-[0-9]{16}~[0-9]{10}") +val admobUnitIdPattern = Regex("ca-app-pub-[0-9]{16}/[0-9]{10}") +val googleTestAppIds = setOf("ca-app-pub-3940256099942544~3347511713") +val googleTestUnitIds = setOf("ca-app-pub-3940256099942544/6300978111") + fun readProperties(propertiesFile: File) = Properties().apply { propertiesFile.inputStream().use { fis -> diff --git a/app/src/foss/AndroidManifest.xml b/app/src/foss/AndroidManifest.xml new file mode 100644 index 0000000..9d376d2 --- /dev/null +++ b/app/src/foss/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/app/src/foss/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt b/app/src/foss/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt new file mode 100644 index 0000000..e854430 --- /dev/null +++ b/app/src/foss/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt @@ -0,0 +1,16 @@ +package com.nagopy.android.aplin.ui.ads + +import android.app.Activity +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AdsViewModel : ViewModel() { + val adsState: StateFlow = MutableStateFlow(AdsStatus.NotAllowed).asStateFlow() + val privacyOptionsRequired: StateFlow = MutableStateFlow(false).asStateFlow() + + fun initialize(activity: Activity) = Unit + + fun showPrivacyOptions(activity: Activity) = Unit +} diff --git a/app/src/foss/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt b/app/src/foss/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt new file mode 100644 index 0000000..9ab8736 --- /dev/null +++ b/app/src/foss/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt @@ -0,0 +1,7 @@ +package com.nagopy.android.aplin.ui.ads.compose + +import androidx.compose.runtime.Composable +import com.nagopy.android.aplin.ui.ads.AdsStatus + +@Composable +fun AdBanner(adsStatus: AdsStatus) = Unit diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 86c81e8..4cc2483 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,9 +6,6 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> - - - + - - - diff --git a/app/src/main/java/com/nagopy/android/aplin/AppModule.kt b/app/src/main/java/com/nagopy/android/aplin/AppModule.kt index 0bec15e..c050445 100644 --- a/app/src/main/java/com/nagopy/android/aplin/AppModule.kt +++ b/app/src/main/java/com/nagopy/android/aplin/AppModule.kt @@ -3,7 +3,6 @@ package com.nagopy.android.aplin import android.app.ActivityManager import android.app.admin.DevicePolicyManager import android.content.pm.PackageManager -import androidx.preference.PreferenceManager import com.nagopy.android.aplin.ui.prefs.UserDataStore import com.nagopy.android.aplin.ui.prefs.dataStore import org.koin.android.ext.koin.androidContext @@ -14,6 +13,5 @@ val appModule = single { androidContext().getSystemService(DevicePolicyManager::class.java) } single { androidContext().packageManager } single { androidContext().getSystemService(ActivityManager::class.java) } - single { PreferenceManager.getDefaultSharedPreferences(androidContext()) } single { UserDataStore(androidContext().dataStore) } } diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/UiModule.kt b/app/src/main/java/com/nagopy/android/aplin/ui/UiModule.kt index 245be30..05b8734 100644 --- a/app/src/main/java/com/nagopy/android/aplin/ui/UiModule.kt +++ b/app/src/main/java/com/nagopy/android/aplin/ui/UiModule.kt @@ -10,5 +10,5 @@ import org.koin.dsl.module val uiModule = module { viewModel { MainViewModel(get(), get(), get(), get(named(CoroutineDispatcherType.IO)), get()) } - viewModel { AdsViewModel(get()) } + viewModel { AdsViewModel() } } diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsStatus.kt b/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsStatus.kt index 53c5a87..ee98324 100644 --- a/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsStatus.kt +++ b/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsStatus.kt @@ -2,8 +2,7 @@ package com.nagopy.android.aplin.ui.ads enum class AdsStatus { NotInitialized, - NonPersonalized, - Personalized, - Denied, + Ready, + NotAllowed, Error, } diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt b/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt deleted file mode 100644 index b87c8e9..0000000 --- a/app/src/main/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt +++ /dev/null @@ -1,250 +0,0 @@ -package com.nagopy.android.aplin.ui.ads - -import android.app.Activity -import android.content.SharedPreferences -import android.os.Bundle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.google.ads.mediation.admob.AdMobAdapter -import com.google.android.gms.ads.AdRequest -import com.google.android.gms.ads.AdView -import com.google.android.gms.ads.MobileAds -import com.google.android.ump.ConsentDebugSettings -import com.google.android.ump.ConsentInformation -import com.google.android.ump.ConsentRequestParameters -import com.google.android.ump.UserMessagingPlatform -import com.nagopy.android.aplin.BuildConfig -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import logcat.LogPriority -import logcat.logcat - -class AdsViewModel( - private val prefs: SharedPreferences, -) : ViewModel() { - private val _adsState = MutableStateFlow(AdsStatus.NotInitialized) - val adsState = - _adsState.stateIn(viewModelScope, SharingStarted.Eagerly, _adsState.value) - - private val _isGDPRState = MutableStateFlow(false) - val isGDPRState = - _isGDPRState.stateIn(viewModelScope, SharingStarted.Eagerly, _isGDPRState.value) - - private lateinit var consentInformation: ConsentInformation - - private fun updateState() { - _adsState.update { - if (isGDPR()) { - when { - canShowPersonalizedAds() -> { - AdsStatus.Personalized - } - canShowAds() -> { - AdsStatus.NonPersonalized - } - else -> { - AdsStatus.Denied - } - } - } else { - AdsStatus.Personalized - } - } - } - - private fun updateGDPRState() { - val isGDPR = isGDPR() - _isGDPRState.update { isGDPR } - } - - fun init(activity: Activity) { - // Set tag for underage of consent. false means users are not underage. - val params = - ConsentRequestParameters - .Builder() - .apply { - if (BuildConfig.DEBUG) { - setConsentDebugSettings( - ConsentDebugSettings - .Builder(activity) - .setDebugGeography(ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_EEA) - // .setDebugGeography(ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_NOT_EEA) - .build(), - ) - } - }.build() - - consentInformation = UserMessagingPlatform.getConsentInformation(activity) - - printLogs("showConsentFormIfNeeded 1") - - consentInformation.requestConsentInfoUpdate( - activity, - params, - { - // The consent information state was updated. - printLogs("requestConsentInfoUpdate") - updateGDPRState() - updateState() - - // You are now ready to check if a form is available. - if (consentInformation.isConsentFormAvailable) { - loadForm(activity) - } - }, - { - // Handle the error. - logcat { "$it" } - printLogs("requestConsentInfoUpdate error") - updateGDPRState() - _adsState.update { AdsStatus.Error } - }, - ) - } - - fun loadForm( - activity: Activity, - force: Boolean = false, - ) { - UserMessagingPlatform.loadConsentForm( - activity, - { consentForm -> - printLogs("loadConsentForm") - updateState() - if (force || consentInformation.consentStatus == ConsentInformation.ConsentStatus.REQUIRED) { - consentForm.show(activity) { - // Handle dismissal by reloading form. - logcat { "consentForm.show FormError=$it" } - loadForm(activity) - } - } - }, - ) { - // / Handle Error. - logcat { "$it" } - printLogs("loadConsentForm error") - _adsState.update { AdsStatus.Error } - } - } - - private fun isGDPR(): Boolean { - val gdpr = prefs.getInt("IABTCF_gdprApplies", 0) - return gdpr == 1 - } - - private fun canShowAds(): Boolean { - // https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details - // https://support.google.com/admob/answer/9760862?hl=en&ref_topic=9756841 - - val purposeConsent = prefs.getString("IABTCF_PurposeConsents", "") ?: "" - val vendorConsent = prefs.getString("IABTCF_VendorConsents", "") ?: "" - val vendorLI = prefs.getString("IABTCF_VendorLegitimateInterests", "") ?: "" - val purposeLI = prefs.getString("IABTCF_PurposeLegitimateInterests", "") ?: "" - - val googleId = 755 - val hasGoogleVendorConsent = hasAttribute(vendorConsent, index = googleId) - val hasGoogleVendorLI = hasAttribute(vendorLI, index = googleId) - - // Minimum required for at least non-personalized ads - return hasConsentFor(listOf(1), purposeConsent, hasGoogleVendorConsent) && - hasConsentOrLegitimateInterestFor( - listOf(2, 7, 9, 10), - purposeConsent, - purposeLI, - hasGoogleVendorConsent, - hasGoogleVendorLI, - ) - } - - private fun canShowPersonalizedAds(): Boolean { - // https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details - // https://support.google.com/admob/answer/9760862?hl=en&ref_topic=9756841 - - val purposeConsent = prefs.getString("IABTCF_PurposeConsents", "") ?: "" - val vendorConsent = prefs.getString("IABTCF_VendorConsents", "") ?: "" - val vendorLI = prefs.getString("IABTCF_VendorLegitimateInterests", "") ?: "" - val purposeLI = prefs.getString("IABTCF_PurposeLegitimateInterests", "") ?: "" - - val googleId = 755 - val hasGoogleVendorConsent = hasAttribute(vendorConsent, index = googleId) - val hasGoogleVendorLI = hasAttribute(vendorLI, index = googleId) - - return hasConsentFor(listOf(1, 3, 4), purposeConsent, hasGoogleVendorConsent) && - hasConsentOrLegitimateInterestFor( - listOf(2, 7, 9, 10), - purposeConsent, - purposeLI, - hasGoogleVendorConsent, - hasGoogleVendorLI, - ) - } - - // Check if a binary string has a "1" at position "index" (1-based) - private fun hasAttribute( - input: String, - index: Int, - ): Boolean = input.length >= index && input[index - 1] == '1' - - // Check if consent is given for a list of purposes - private fun hasConsentFor( - purposes: List, - purposeConsent: String, - hasVendorConsent: Boolean, - ): Boolean = purposes.all { p -> hasAttribute(purposeConsent, p) } && hasVendorConsent - - // Check if a vendor either has consent or legitimate interest for a list of purposes - private fun hasConsentOrLegitimateInterestFor( - purposes: List, - purposeConsent: String, - purposeLI: String, - hasVendorConsent: Boolean, - hasVendorLI: Boolean, - ): Boolean = - purposes.all { p -> - (hasAttribute(purposeLI, p) && hasVendorLI) || - (hasAttribute(purposeConsent, p) && hasVendorConsent) - } - - private fun printLogs(msg: String) { - logcat(LogPriority.VERBOSE) { - """ ----------------------- -msg: $msg -isGDPR: ${isGDPR()} -canShowAds: ${canShowAds()} -canShowPersonalizedAds: ${canShowPersonalizedAds()} -consentStatus: ${consentInformation.consentStatus} -isConsentFormAvailable: ${consentInformation.isConsentFormAvailable} ----------------------- - """ - } - } - - fun updateAds( - state: AdsStatus, - adView: AdView, - ) { - when (state) { - AdsStatus.Personalized -> { - MobileAds.initialize(adView.context) - adView.loadAd(AdRequest.Builder().build()) - } - AdsStatus.NonPersonalized -> { - MobileAds.initialize(adView.context) - val extras = Bundle() - extras.putString("npa", "1") - val request = - AdRequest - .Builder() - .addNetworkExtrasBundle(AdMobAdapter::class.java, extras) - .build() - adView.loadAd(request) - } - else -> { - // noop - } - } - } -} diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt b/app/src/main/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt deleted file mode 100644 index 823e249..0000000 --- a/app/src/main/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.nagopy.android.aplin.ui.ads.compose - -import android.content.res.Resources -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.google.android.gms.ads.AdSize -import com.google.android.gms.ads.AdView -import com.nagopy.android.aplin.BuildConfig -import com.nagopy.android.aplin.ui.ads.AdsStatus - -@Composable -fun AdBanner( - state: AdsStatus, - updateAds: (AdsStatus, AdView) -> Unit, -) { - if (state != AdsStatus.Personalized && state != AdsStatus.NonPersonalized) { - return - } - - if (LocalInspectionMode.current) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(36.dp) - .background(Color.Red), - ) - } else { - AndroidView( - modifier = Modifier.fillMaxWidth(), - factory = { context -> - val adView = AdView(context) - val displayMetrics = Resources.getSystem().displayMetrics - val width = (displayMetrics.widthPixels / displayMetrics.density).toInt() - adView.setAdSize(AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, width)) - adView.adUnitId = BuildConfig.AD_UNIT_ID - adView - }, - update = { updateAds.invoke(state, it) }, - ) - } -} diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogLoader.kt b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogLoader.kt new file mode 100644 index 0000000..1cb8b47 --- /dev/null +++ b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogLoader.kt @@ -0,0 +1,17 @@ +package com.nagopy.android.aplin.ui.licenses + +import android.content.res.AssetManager + +class LicenseCatalogLoader( + private val assets: AssetManager, +) { + fun load(): List = + assets + .open(ASSET_PATH) + .bufferedReader() + .use { LicenseCatalogParser().parse(it.readText()) } + + companion object { + const val ASSET_PATH = "app/cash/licensee/artifacts.json" + } +} diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogParser.kt b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogParser.kt new file mode 100644 index 0000000..5be119b --- /dev/null +++ b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogParser.kt @@ -0,0 +1,54 @@ +package com.nagopy.android.aplin.ui.licenses + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class LicenseCatalogParser { + fun parse(json: String): List { + val artifacts = jsonParser.parseToJsonElement(json).jsonArray + return artifacts.map { it.jsonObject.toLicenseEntry() } + } + + private fun JsonObject.toLicenseEntry(): LicenseEntry = + LicenseEntry( + groupId = getString("groupId"), + artifactId = getString("artifactId"), + version = getString("version"), + name = getString("name"), + licenses = + listOf("spdxLicenses", "unknownLicenses") + .flatMap { getLicenseReferences(it) }, + sourceUrl = + get("scm") + ?.jsonObject + ?.getString("url") + .orEmpty(), + ) + + private fun JsonObject.getLicenseReferences(key: String): List = + get(key) + ?.jsonArray + ?.map { license -> + license.jsonObject.let { licenseObject -> + LicenseReference( + name = licenseObject.getString("name"), + identifier = licenseObject.getString("identifier"), + url = licenseObject.getString("url"), + ) + } + }.orEmpty() + + private fun JsonObject.getString(key: String): String = + get(key) + ?.jsonPrimitive + ?.contentOrNull + .orEmpty() + + private companion object { + val jsonParser = Json { ignoreUnknownKeys = true } + } +} diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseEntry.kt b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseEntry.kt new file mode 100644 index 0000000..98d70f6 --- /dev/null +++ b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicenseEntry.kt @@ -0,0 +1,19 @@ +package com.nagopy.android.aplin.ui.licenses + +data class LicenseEntry( + val groupId: String, + val artifactId: String, + val version: String, + val name: String, + val licenses: List, + val sourceUrl: String, +) { + val coordinate: String + get() = "$groupId:$artifactId:$version" +} + +data class LicenseReference( + val name: String, + val identifier: String, + val url: String, +) diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicensesActivity.kt b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicensesActivity.kt new file mode 100644 index 0000000..38eca54 --- /dev/null +++ b/app/src/main/java/com/nagopy/android/aplin/ui/licenses/LicensesActivity.kt @@ -0,0 +1,88 @@ +package com.nagopy.android.aplin.ui.licenses + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nagopy.android.aplin.R +import com.nagopy.android.aplin.ui.theme.AplinTheme + +class LicensesActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val entries = + runCatching { LicenseCatalogLoader(assets).load() } + .getOrElse { emptyList() } + setContent { + AplinTheme { + LicenseScreen(entries) + } + } + } +} + +@Composable +private fun LicenseScreen(entries: List) { + Scaffold( + topBar = { TopAppBar(title = { Text(stringResource(R.string.licenses)) }) }, + ) { padding -> + if (entries.isEmpty()) { + Text( + text = stringResource(R.string.licenses_unavailable), + modifier = Modifier.padding(padding).padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + items(entries) { entry -> + LicenseRow(entry) + } + } + } + } +} + +@Composable +private fun LicenseRow(entry: LicenseEntry) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(text = entry.coordinate, style = MaterialTheme.typography.subtitle1) + if (entry.name.isNotBlank()) { + Text(text = entry.name, style = MaterialTheme.typography.body2) + } + Text(text = entry.version, style = MaterialTheme.typography.body2) + if (entry.licenses.isEmpty()) { + Text(text = stringResource(R.string.license_unknown), style = MaterialTheme.typography.body2) + } else { + entry.licenses.forEach { license -> + val licenseLabel = + listOf(license.name, license.identifier) + .filter(String::isNotBlank) + .joinToString(" / ") + .ifBlank { stringResource(R.string.license_unknown) } + Text(text = licenseLabel, style = MaterialTheme.typography.body2) + if (license.url.isNotBlank()) { + Text(text = license.url, style = MaterialTheme.typography.body2) + } + } + } + if (entry.sourceUrl.isNotBlank()) { + Text( + text = stringResource(R.string.license_source, entry.sourceUrl), + style = MaterialTheme.typography.body2, + ) + } + } +} diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/main/MainActivity.kt b/app/src/main/java/com/nagopy/android/aplin/ui/main/MainActivity.kt index 95fb916..4770452 100644 --- a/app/src/main/java/com/nagopy/android/aplin/ui/main/MainActivity.kt +++ b/app/src/main/java/com/nagopy/android/aplin/ui/main/MainActivity.kt @@ -35,19 +35,18 @@ class MainActivity : ComponentActivity() { sharePackages = { packages -> mainViewModel.sharePackages(this@MainActivity, packages) }, - startOssLicensesActivity = { - mainViewModel.startOssLicensesActivity(this) + startLicensesActivity = { + mainViewModel.startLicensesActivity(this) }, adsStatus = adsViewModel.adsState.collectAsState().value, - isGDPR = adsViewModel.isGDPRState.collectAsState().value, - showConsentForm = { - adsViewModel.loadForm(this, force = true) + privacyOptionsRequired = adsViewModel.privacyOptionsRequired.collectAsState().value, + showPrivacyOptions = { + adsViewModel.showPrivacyOptions(this) }, - updateAds = adsViewModel::updateAds, ) } - adsViewModel.init(this) + adsViewModel.initialize(this) } override fun onRestart() { diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/main/MainViewModel.kt b/app/src/main/java/com/nagopy/android/aplin/ui/main/MainViewModel.kt index 971b825..56d0c64 100644 --- a/app/src/main/java/com/nagopy/android/aplin/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/nagopy/android/aplin/ui/main/MainViewModel.kt @@ -7,12 +7,14 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings +import android.widget.Toast import androidx.core.app.ShareCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import com.nagopy.android.aplin.R import com.nagopy.android.aplin.domain.model.PackageModel import com.nagopy.android.aplin.domain.usecase.LoadPackagesUseCase +import com.nagopy.android.aplin.ui.licenses.LicensesActivity import com.nagopy.android.aplin.ui.prefs.UserDataStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +24,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import logcat.logcat -import java.net.URLEncoder class MainViewModel( activityManager: ActivityManager, @@ -107,29 +108,26 @@ class MainViewModel( .putExtra(SearchManager.QUERY, "${packageModel.label} ${packageModel.packageName}") if (isLaunchable(actionWebSearch)) { - activity.startActivity(actionWebSearch) - } else { - val url = "https://www.google.com/search?q=${ - URLEncoder.encode( - packageModel.label, - "UTF-8", - ) - }%20${packageModel.packageName}" - val actionView = - Intent(Intent.ACTION_VIEW) - .setData(Uri.parse(url)) - if (isLaunchable(actionView)) { - activity.startActivity(actionView) - } else { - logcat { "ActivityNotFound" } + try { + activity.startActivity(actionWebSearch) + } catch (exception: Exception) { + logcat { "Unable to start web search: $exception" } + showWebSearchUnavailable(activity) } + } else { + logcat { "No web search activity available" } + showWebSearchUnavailable(activity) } } + private fun showWebSearchUnavailable(activity: Activity) { + Toast.makeText(activity, R.string.web_search_unavailable, Toast.LENGTH_SHORT).show() + } + private fun isLaunchable(intent: Intent): Boolean = intent.resolveActivity(packageManager) != null - fun startOssLicensesActivity(activity: Activity) { - activity.startActivity(Intent(activity, OssLicensesMenuActivity::class.java)) + fun startLicensesActivity(activity: Activity) { + activity.startActivity(Intent(activity, LicensesActivity::class.java)) } fun sharePackages( diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/MainScreen.kt b/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/MainScreen.kt index 3f47aff..64f81b4 100644 --- a/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/MainScreen.kt +++ b/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/MainScreen.kt @@ -30,9 +30,9 @@ fun MainScreen( state: MainUiState, startDetailSettingsActivity: (String) -> Unit, searchByWeb: (PackageModel) -> Unit, - startOssLicensesActivity: () -> Unit, - isGDPR: Boolean, - showConsentForm: () -> Unit, + startLicensesActivity: () -> Unit, + privacyOptionsRequired: Boolean, + showPrivacyOptions: () -> Unit, ) { if (state.packagesModel == null) { Loading() @@ -43,9 +43,9 @@ fun MainScreen( state.searchText, startDetailSettingsActivity, searchByWeb, - startOssLicensesActivity, - isGDPR, - showConsentForm, + startLicensesActivity, + privacyOptionsRequired, + showPrivacyOptions, ) } } @@ -57,9 +57,9 @@ fun MainScreenLoaded( searchText: String, startDetailSettingsActivity: (String) -> Unit, searchByWeb: (PackageModel) -> Unit, - startOssLicensesActivity: () -> Unit, - isGDPR: Boolean, - showConsentForm: () -> Unit, + startLicensesActivity: () -> Unit, + privacyOptionsRequired: Boolean, + showPrivacyOptions: () -> Unit, ) { Column( modifier = @@ -80,13 +80,13 @@ fun MainScreenLoaded( Spacer(modifier = Modifier.height(12.dp)) } - Button(onClick = startOssLicensesActivity) { + Button(onClick = startLicensesActivity) { Text(stringResource(id = R.string.licenses)) } - if (isGDPR) { + if (privacyOptionsRequired) { Spacer(modifier = Modifier.height(12.dp)) - Button(onClick = showConsentForm) { + Button(onClick = showPrivacyOptions) { Text(stringResource(id = R.string.change_consent_state)) } } @@ -120,11 +120,11 @@ fun MainScreenLoadedPreview() { navController = rememberNavController(), packagesModel = PackagesModel(packages, packages, packages, packages), searchText = "", - startOssLicensesActivity = {}, + startLicensesActivity = {}, searchByWeb = {}, startDetailSettingsActivity = {}, - isGDPR = true, - showConsentForm = {}, + privacyOptionsRequired = true, + showPrivacyOptions = {}, ) } } diff --git a/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/RootScreen.kt b/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/RootScreen.kt index 60950f9..b784afb 100644 --- a/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/RootScreen.kt +++ b/app/src/main/java/com/nagopy/android/aplin/ui/main/compose/RootScreen.kt @@ -15,7 +15,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.google.android.gms.ads.AdView import com.nagopy.android.aplin.domain.model.PackageModel import com.nagopy.android.aplin.ui.ads.AdsStatus import com.nagopy.android.aplin.ui.ads.compose.AdBanner @@ -33,11 +32,10 @@ fun RootScreen( startDetailSettingsActivity: (String) -> Unit, searchByWeb: (PackageModel) -> Unit, sharePackages: (List) -> Unit, - startOssLicensesActivity: () -> Unit, + startLicensesActivity: () -> Unit, adsStatus: AdsStatus, - isGDPR: Boolean, - showConsentForm: () -> Unit, - updateAds: (AdsStatus, AdView) -> Unit, + privacyOptionsRequired: Boolean, + showPrivacyOptions: () -> Unit, ) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -90,9 +88,9 @@ fun RootScreen( state = state, startDetailSettingsActivity = startDetailSettingsActivity, searchByWeb = searchByWeb, - startOssLicensesActivity = startOssLicensesActivity, - isGDPR = isGDPR, - showConsentForm = showConsentForm, + startLicensesActivity = startLicensesActivity, + privacyOptionsRequired = privacyOptionsRequired, + showPrivacyOptions = showPrivacyOptions, ) } Screen.appListScreens.forEach { appListScreen -> @@ -123,10 +121,7 @@ fun RootScreen( } } - AdBanner( - state = adsStatus, - updateAds = updateAds, - ) + AdBanner(adsStatus) } } } diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 2160887..4b6eb26 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -6,6 +6,10 @@ 無効化済みのアプリ 全てのアプリ オープンソースライセンス + ライセンス情報を読み込めません。 + 不明なライセンス + ソース:%s + ウェブ検索アプリが見つかりません。 共有 検索 アプリ名・パッケージ名で検索する diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f162080..dca95e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,10 @@ Disabled Apps All Apps Open source licenses + License information is unavailable. + Unknown license + Source: %s + No web search app is available. Change consent state Share Search diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 0000000..873c630 --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/app/src/play/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt b/app/src/play/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt new file mode 100644 index 0000000..dca55e8 --- /dev/null +++ b/app/src/play/java/com/nagopy/android/aplin/ui/ads/AdsViewModel.kt @@ -0,0 +1,147 @@ +package com.nagopy.android.aplin.ui.ads + +import android.app.Activity +import androidx.lifecycle.ViewModel +import com.google.android.gms.ads.MobileAds +import com.google.android.ump.ConsentInformation +import com.google.android.ump.ConsentRequestParameters +import com.google.android.ump.UserMessagingPlatform +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import logcat.logcat +import java.lang.ref.WeakReference + +class AdsViewModel : ViewModel() { + private val _adsState = MutableStateFlow(AdsStatus.NotInitialized) + val adsState: StateFlow = _adsState.asStateFlow() + + private val _privacyOptionsRequired = MutableStateFlow(false) + val privacyOptionsRequired: StateFlow = _privacyOptionsRequired.asStateFlow() + + private var mobileAdsInitializationState = MobileAdsInitializationState.NotStarted + private var consentInformation: ConsentInformation? = null + private var consentUpdateRequested = false + private var umpOperationInProgress = false + private var latestActivity: WeakReference? = null + + fun initialize(activity: Activity) { + latestActivity = WeakReference(activity) + val information = + consentInformation + ?: UserMessagingPlatform.getConsentInformation(activity).also { + consentInformation = it + } + updatePrivacyOptionsRequired(information) + if (consentUpdateRequested) { + if (!umpOperationInProgress) { + reconcileAdsState(information) + } + return + } + consentUpdateRequested = true + umpOperationInProgress = true + val params = ConsentRequestParameters.Builder().build() + + information.requestConsentInfoUpdate( + activity, + params, + { + updatePrivacyOptionsRequired(information) + val currentActivity = latestActivity?.get() + if (currentActivity == null || !currentActivity.isUsable()) { + finishUmpOperation(information) + } else { + UserMessagingPlatform.loadAndShowConsentFormIfRequired(currentActivity) { error -> + error?.let { logcat { "Consent form error: $it" } } + finishUmpOperation(information) + } + } + }, + { error -> + logcat { "Consent info update error: $error" } + // UMP retains the previous session's valid consent. It may still allow ads. + updatePrivacyOptionsRequired(information) + finishUmpOperation(information, errorState = true) + }, + ) + } + + fun showPrivacyOptions(activity: Activity) { + latestActivity = WeakReference(activity) + val information = consentInformation ?: return + if (umpOperationInProgress) { + return + } + if (information.privacyOptionsRequirementStatus != + ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED + ) { + return + } + umpOperationInProgress = true + UserMessagingPlatform.showPrivacyOptionsForm(activity) { error -> + error?.let { logcat { "Privacy options form error: $it" } } + finishUmpOperation(information, errorState = error != null) + } + } + + private fun updatePrivacyOptionsRequired(information: ConsentInformation) { + _privacyOptionsRequired.value = + information.privacyOptionsRequirementStatus == + ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED + } + + private fun reconcileAdsState( + information: ConsentInformation, + errorState: Boolean = false, + ) { + if (!information.canRequestAds()) { + _adsState.value = if (errorState) AdsStatus.Error else AdsStatus.NotAllowed + return + } + when (mobileAdsInitializationState) { + MobileAdsInitializationState.NotStarted -> initializeMobileAdsIfNeeded() + MobileAdsInitializationState.InProgress -> _adsState.value = AdsStatus.NotInitialized + MobileAdsInitializationState.Initialized -> _adsState.value = AdsStatus.Ready + } + } + + private fun finishUmpOperation( + information: ConsentInformation, + errorState: Boolean = false, + ) { + if (!umpOperationInProgress) { + return + } + updatePrivacyOptionsRequired(information) + umpOperationInProgress = false + reconcileAdsState(information, errorState) + } + + private fun initializeMobileAdsIfNeeded() { + if (mobileAdsInitializationState != MobileAdsInitializationState.NotStarted) { + return + } + val context = latestActivity?.get()?.applicationContext ?: return + mobileAdsInitializationState = MobileAdsInitializationState.InProgress + MobileAds.initialize(context) { + mobileAdsInitializationState = MobileAdsInitializationState.Initialized + consentInformation?.let { information -> + reconcileAdsState(information) + } + } + } + + private fun Activity.isUsable(): Boolean = !isFinishing && !isDestroyed + + override fun onCleared() { + latestActivity = null + super.onCleared() + } + + private enum class MobileAdsInitializationState { + NotStarted, + InProgress, + Initialized, + } +} diff --git a/app/src/play/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt b/app/src/play/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt new file mode 100644 index 0000000..935dd3e --- /dev/null +++ b/app/src/play/java/com/nagopy/android/aplin/ui/ads/compose/AdBanner.kt @@ -0,0 +1,67 @@ +package com.nagopy.android.aplin.ui.ads.compose + +import android.content.res.Resources +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.AdSize +import com.google.android.gms.ads.AdView +import com.nagopy.android.aplin.BuildConfig +import com.nagopy.android.aplin.ui.ads.AdsStatus + +@Composable +fun AdBanner(adsStatus: AdsStatus) { + if (adsStatus != AdsStatus.Ready) { + return + } + val lifecycleOwner = LocalLifecycleOwner.current + val adViewReference = remember { AdViewReference() } + + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> adViewReference.value?.resume() + Lifecycle.Event.ON_PAUSE -> adViewReference.value?.pause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + AdView(context).apply { + val displayMetrics = Resources.getSystem().displayMetrics + val width = (displayMetrics.widthPixels / displayMetrics.density).toInt() + setAdSize(AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, width)) + adUnitId = BuildConfig.AD_UNIT_ID + adViewReference.value = this + loadAd(AdRequest.Builder().build()) + } + }, + update = {}, + onRelease = { releasedAdView -> + releasedAdView.pause() + releasedAdView.destroy() + if (adViewReference.value === releasedAdView) { + adViewReference.value = null + } + }, + ) +} + +private class AdViewReference { + var value: AdView? = null +} diff --git a/app/src/test/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogParserTest.kt b/app/src/test/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogParserTest.kt new file mode 100644 index 0000000..9fbf58c --- /dev/null +++ b/app/src/test/java/com/nagopy/android/aplin/ui/licenses/LicenseCatalogParserTest.kt @@ -0,0 +1,54 @@ +package com.nagopy.android.aplin.ui.licenses + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LicenseCatalogParserTest { + @Test + fun parseDisplaysCoordinateAndEveryLicenseField() { + val json = + """ + [{ + "groupId":"com.example", + "artifactId":"fallback-artifact", + "version":"1.2.3", + "name":"Readable Name", + "spdxLicenses":[ + {"identifier":"Apache-2.0","name":"Apache License 2.0","url":"https://spdx.org/licenses/Apache-2.0.html"}, + {"identifier":"MIT","name":"MIT License","url":"https://spdx.org/licenses/MIT.html"} + ], + "unknownLicenses":[{"name":"Custom License","url":"https://example.com/license"}], + "scm":{"url":"https://example.com/source"} + }] + """.trimIndent() + + assertEquals( + LicenseEntry( + groupId = "com.example", + artifactId = "fallback-artifact", + version = "1.2.3", + name = "Readable Name", + licenses = + listOf( + LicenseReference( + name = "Apache License 2.0", + identifier = "Apache-2.0", + url = "https://spdx.org/licenses/Apache-2.0.html", + ), + LicenseReference( + name = "MIT License", + identifier = "MIT", + url = "https://spdx.org/licenses/MIT.html", + ), + LicenseReference( + name = "Custom License", + identifier = "", + url = "https://example.com/license", + ), + ), + sourceUrl = "https://example.com/source", + ), + LicenseCatalogParser().parse(json).single(), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d30a324..4e590aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,10 @@ buildscript { repositories { google() + mavenCentral() } dependencies { - classpath(libs.ossLicenses) + classpath(libs.licenseePlugin) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8b221e6..a43a5c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,6 @@ targetSdk = "36" javaVersion = "17" # plugins -ossLicensesPlugin = "0.10.6" androidGradlePlugin = "8.13.2" kotlinAndroidPlugin = "2.3.21" ktlintPlugin = "14.2.0" @@ -29,16 +28,15 @@ koinAndroid = "4.2.2" kotlinReflect = "2.3.21" logcat = "0.4" navigationCompose = "2.9.8" -ossLicensesPluginVersion = "0.10.6" -playServicesOssLicenses = "17.1.0" appcompat = "1.7.1" -playServicesAds = "23.5.0" -ump = "3.1.0" -preferenceKtx = "1.2.1" +licenseePlugin = "1.14.1" +playServicesAds = "25.4.0" +ump = "4.0.0" mockk = "1.14.11" mockkAndroid = "1.14.11" mockkAgent = "1.14.11" datastorePreferences = "1.2.1" +kotlinxSerializationJson = "1.9.0" [plugins] androidApp = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -48,7 +46,7 @@ kotlinPluginCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "koverPlugin" } [libraries] -ossLicenses = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "ossLicensesPlugin" } +licenseePlugin = { group = "app.cash.licensee", name = "licensee-gradle-plugin", version.ref = "licenseePlugin" } androidxKtx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" } composeUi = { group = "androidx.compose.ui", name = "ui", version.ref = "composeUi" } composeMaterial = { group = "androidx.compose.material", name = "material", version.ref = "composeMaterial" } @@ -66,13 +64,11 @@ koinAndroid = { group = "io.insert-koin", name = "koin-android", version.ref = " kotlinReflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlinReflect" } logcat = { group = "com.squareup.logcat", name = "logcat", version.ref = "logcat" } navigationCompose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } -oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "ossLicensesPluginVersion" } -playServicesOssLicenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "playServicesOssLicenses" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } playServicesAds = { group = "com.google.android.gms", name = "play-services-ads", version.ref = "playServicesAds" } ump = { group = "com.google.android.ump", name = "user-messaging-platform", version.ref = "ump" } -preferenceKtx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockkAndroid = { group = "io.mockk", name = "mockk-android", version.ref = "mockkAndroid" } mockkAgent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockkAgent" } datastorePreferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } +kotlinxSerializationJson = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }