Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 21 additions & 8 deletions DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -119,14 +120,26 @@ 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.
- `espresso-core`, `androidx.test:rules`, and Compose `ui-test-junit4` were removed because `rg` found no source or test usage. The remaining AndroidX Test dependencies are used by the instrumented tests.
- 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

Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
150 changes: 133 additions & 17 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import com.android.build.gradle.api.ApplicationVariant
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 ->
Expand Down
24 changes: 24 additions & 0 deletions app/src/foss/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission
android:name="android.permission.INTERNET"
tools:node="remove" />
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />

<application>
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
tools:node="remove" />
<meta-data
android:name="com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT"
tools:node="remove" />
</application>

</manifest>
Loading
Loading