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" }