diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0a4ff..839a79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ # Changelog -## 0.2.1 (TBD) +## 0.3.0 (TBD) +- Lowered the minimum supported Android API level from 29 (Android 10) to 27 + (Android 8.1). Device data collection on API 27 and 28 falls back to + pre-API-28 methods for the app version code and MediaDRM cleanup; no collected + signals are lost. - Fixed `enableLogging` not being forwarded from `SdkConfig` to `DeviceDataCollector`, which caused collector-level error logs to be silently suppressed even when logging was explicitly enabled. diff --git a/CLAUDE.md b/CLAUDE.md index 9bd72e7..89cf17d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,15 +212,6 @@ When adding new public APIs: All dependencies are centralized in `gradle/libs.versions.toml`: -**Key Dependencies:** - -- Ktor 2.3.7 (HTTP client with Android engine) -- kotlinx.serialization 1.6.2 (JSON serialization) -- kotlinx.coroutines 1.7.3 (async operations) -- Detekt 1.23.5 (Kotlin linting) -- ktlint 12.1.0 (code formatting) -- Dokka 1.9.10 (API documentation) - **To update a dependency:** 1. Edit version in `gradle/libs.versions.toml` diff --git a/README.md b/README.md index 31039d3..ba6e543 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Android SDK for collecting and reporting device data to MaxMind. ## Requirements -- Android API 29+ (Android 10+) -- Kotlin 1.9.22+ +- Android API 27+ (Android 8.1+) +- Kotlin 2.2.21+ - AndroidX libraries ## Installation diff --git a/device-sdk/src/main/java/com/maxmind/device/collector/DeviceIDsCollector.kt b/device-sdk/src/main/java/com/maxmind/device/collector/DeviceIDsCollector.kt index eba655a..e51a30f 100644 --- a/device-sdk/src/main/java/com/maxmind/device/collector/DeviceIDsCollector.kt +++ b/device-sdk/src/main/java/com/maxmind/device/collector/DeviceIDsCollector.kt @@ -3,6 +3,7 @@ package com.maxmind.device.collector import android.annotation.SuppressLint import android.content.Context import android.media.MediaDrm +import android.os.Build import android.provider.Settings import android.util.Base64 import android.util.Log @@ -46,7 +47,12 @@ internal class DeviceIDsCollector( val deviceId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID) Base64.encodeToString(deviceId, Base64.NO_WRAP) } finally { - mediaDrm.close() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + mediaDrm.close() + } else { + @Suppress("DEPRECATION") + mediaDrm.release() + } } } catch ( @Suppress("TooGenericExceptionCaught", "SwallowedException") diff --git a/device-sdk/src/main/java/com/maxmind/device/collector/helper/InstallationInfoHelper.kt b/device-sdk/src/main/java/com/maxmind/device/collector/helper/InstallationInfoHelper.kt index a8d8e8b..2edcfbd 100644 --- a/device-sdk/src/main/java/com/maxmind/device/collector/helper/InstallationInfoHelper.kt +++ b/device-sdk/src/main/java/com/maxmind/device/collector/helper/InstallationInfoHelper.kt @@ -22,7 +22,12 @@ internal class InstallationInfoHelper( public fun collect(): InstallationInfo { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) - val versionCode = packageInfo.longVersionCode + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + packageInfo.versionCode.toLong() + } val installerPackage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/device-sdk/src/test/java/com/maxmind/device/collector/DeviceIDsCollectorApi27RobolectricTest.kt b/device-sdk/src/test/java/com/maxmind/device/collector/DeviceIDsCollectorApi27RobolectricTest.kt new file mode 100644 index 0000000..6b04418 --- /dev/null +++ b/device-sdk/src/test/java/com/maxmind/device/collector/DeviceIDsCollectorApi27RobolectricTest.kt @@ -0,0 +1,37 @@ +package com.maxmind.device.collector + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.robolectric.annotation.Config +import tech.apter.junit.jupiter.robolectric.RobolectricExtension + +/** + * Best-effort Robolectric smoke test for [DeviceIDsCollector] on API 27 (Android 8.1). + * + * On API < 28 the collector releases [android.media.MediaDrm] via the deprecated + * `release()` lifecycle call instead of `close()` (which only exists from API 28). + * This test pins the SDK to 27 and asserts that [DeviceIDsCollector.collect] returns a + * [com.maxmind.device.model.DeviceIDs] without throwing. + * + * Known limitation: Robolectric's MediaDrm shadow does not exercise the real + * `release()`/`close()` split, so this test cannot fully validate the cleanup branch — + * consistent with how [DeviceIDsCollectorTest] treats MediaDRM as null in unit tests. + * The real branch is validated by lint's NewApi check and (optionally) an instrumented + * test on an API-27 device. + */ +@ExtendWith(RobolectricExtension::class) +@Config(sdk = [27]) +internal class DeviceIDsCollectorApi27RobolectricTest { + @Test + internal fun `collect returns DeviceIDs without throwing on API 27`() { + val context = ApplicationProvider.getApplicationContext() + val collector = DeviceIDsCollector(context) + + val result = collector.collect() + + assertNotNull(result) + } +} diff --git a/device-sdk/src/test/java/com/maxmind/device/collector/helper/InstallationInfoHelperApi27RobolectricTest.kt b/device-sdk/src/test/java/com/maxmind/device/collector/helper/InstallationInfoHelperApi27RobolectricTest.kt new file mode 100644 index 0000000..c045292 --- /dev/null +++ b/device-sdk/src/test/java/com/maxmind/device/collector/helper/InstallationInfoHelperApi27RobolectricTest.kt @@ -0,0 +1,59 @@ +package com.maxmind.device.collector.helper + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import tech.apter.junit.jupiter.robolectric.RobolectricExtension + +/** + * Robolectric-based tests for [InstallationInfoHelper] on API 27 (Android 8.1). + * + * On API < 28 the helper falls back to the deprecated int [android.content.pm.PackageInfo.versionCode] + * field (read as `.versionCode.toLong()`) and to [android.content.pm.PackageManager.getInstallerPackageName] + * for the installer. JUnit 5's Robolectric extension can only set `@Config(sdk)` at the class + * level, so this separate class pins the SDK to 27 to exercise those legacy branches; the + * API-28+ branches stay covered by [InstallationInfoHelperRobolectricTest] at sdk 29. + */ +@ExtendWith(RobolectricExtension::class) +@Config(sdk = [27]) +internal class InstallationInfoHelperApi27RobolectricTest { + @Test + internal fun `collect reads versionCode from legacy int field on API 27`() { + val context = ApplicationProvider.getApplicationContext() + val shadowPm = Shadows.shadowOf(context.packageManager) + + val packageInfo = shadowPm.getInternalMutablePackageInfo(context.packageName) + // Set only the legacy int versionCode field. The API-28 longVersionCode accessor does + // not exist in the API-27 runtime Robolectric loads here, so we cannot (and need not) + // pack a major version. This also makes the test discriminate the branch: if the + // production guard regressed to read packageInfo.longVersionCode at API 27, collect() + // would throw NoSuchMethodError (it is not caught) and this assertion would never run. + @Suppress("DEPRECATION") + packageInfo.versionCode = 456 + packageInfo.versionName = "9.8.7" + + val helper = InstallationInfoHelper(context) + val result = helper.collect() + + assertEquals(456L, result.versionCode, "versionCode should come from the legacy int field on API 27") + assertEquals("9.8.7", result.versionName, "versionName should match stubbed value") + } + + @Test + internal fun `collect resolves installer via legacy branch without throwing on API 27`() { + // On API < 30 the helper takes the getInstallerPackageName branch. The installer is + // typically null in the Robolectric environment; we only verify the legacy branch is + // exercised without throwing (mirroring the sdk-29 InstallationInfoHelperRobolectricTest). + val context = ApplicationProvider.getApplicationContext() + val helper = InstallationInfoHelper(context) + + val result = helper.collect() + + assertNotNull(result) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd1abe2..11459a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # SDK Versions -minSdk = "29" +minSdk = "27" targetSdk = "36" compileSdk = "36" diff --git a/lychee.toml b/lychee.toml index 25b099c..16fa09a 100644 --- a/lychee.toml +++ b/lychee.toml @@ -4,8 +4,10 @@ # Run locally with: # lychee './**/*.md' './**/*.kt' './**/*.java' -# Include URL fragments in checks -include_fragments = true +# Check fragments in links. Since lychee 0.24 this is a string enum +# (none | anchor-only | text-only | full), not a boolean. "full" checks both +# anchor fragments (#section) and text fragments (#:~:text=...). +include_fragments = "full" # Don't allow any redirects, so links that have moved are surfaced and can be # updated to their canonical destination. diff --git a/mise.lock b/mise.lock index 156e912..5b2b8e0 100644 --- a/mise.lock +++ b/mise.lock @@ -1,29 +1,133 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html +[[tools.android-sdk]] +version = "1.0" +backend = "vfox:mise-plugins/vfox-android-sdk" + +[tools.android-sdk."platforms.linux-arm64"] +url = "https://dl.google.com/android/repository/commandlinetools-linux-6200805_latest.zip" + +[tools.android-sdk."platforms.linux-arm64-musl"] +url = "https://dl.google.com/android/repository/commandlinetools-linux-6200805_latest.zip" + +[tools.android-sdk."platforms.linux-x64"] +url = "https://dl.google.com/android/repository/commandlinetools-linux-6200805_latest.zip" + +[tools.android-sdk."platforms.linux-x64-musl"] +url = "https://dl.google.com/android/repository/commandlinetools-linux-6200805_latest.zip" + +[tools.android-sdk."platforms.macos-arm64"] +url = "https://dl.google.com/android/repository/commandlinetools-mac-6200805_latest.zip" + +[tools.android-sdk."platforms.macos-x64"] +url = "https://dl.google.com/android/repository/commandlinetools-mac-6200805_latest.zip" + +[tools.android-sdk."platforms.windows-x64"] +url = "https://dl.google.com/android/repository/commandlinetools-win-6200805_latest.zip" + +[[tools."github:houseabsolute/precious"]] +version = "0.11.0" +backend = "github:houseabsolute/precious" + +[tools."github:houseabsolute/precious"."platforms.linux-arm64"] +checksum = "sha256:44d98091a988671786a99d8025b169c76bb08718ffa8f793a80ae1aa97ca5472" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-Linux-musl-arm64.tar.gz" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434639525" + +[tools."github:houseabsolute/precious"."platforms.linux-arm64-musl"] +checksum = "sha256:44d98091a988671786a99d8025b169c76bb08718ffa8f793a80ae1aa97ca5472" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-Linux-musl-arm64.tar.gz" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434639525" + +[tools."github:houseabsolute/precious"."platforms.linux-x64"] +checksum = "sha256:6a267a3e309ebd39ef36352e283b2ad70066ccb61e4b04785afbd85df3fa76b4" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-Linux-musl-x86_64.tar.gz" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434639494" + +[tools."github:houseabsolute/precious"."platforms.linux-x64-musl"] +checksum = "sha256:6a267a3e309ebd39ef36352e283b2ad70066ccb61e4b04785afbd85df3fa76b4" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-Linux-musl-x86_64.tar.gz" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434639494" + +[tools."github:houseabsolute/precious"."platforms.macos-arm64"] +checksum = "sha256:e6cbdba5261c15e3eb9208af928facff50c883bb11c0dd0c10766379285c75b9" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-macOS-arm64.tar.gz" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434639276" + +[tools."github:houseabsolute/precious"."platforms.macos-x64"] +checksum = "sha256:5500304c379686acd0cb852ad9f32e20baaab939f7fbc1f75f1ec0583f4bd676" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-macOS-x86_64.tar.gz" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434639281" + +[tools."github:houseabsolute/precious"."platforms.windows-x64"] +checksum = "sha256:bc5dfb482675ef79798fa578dc5f8edea1cd9f2b5d7bdc5bb02b4a9590f54467" +url = "https://github.com/houseabsolute/precious/releases/download/v0.11.0/precious-Windows-msvc-x86_64.zip" +url_api = "https://api.github.com/repos/houseabsolute/precious/releases/assets/434640219" + +[[tools.java]] +version = "temurin-21.0.11+10.0.LTS" +backend = "core:java" + +[tools.java."platforms.linux-x64"] +checksum = "sha256:4b2220e232a97997b436ca6ab15cbf70171ecff52958a46159dfa5a8c44ca4de" +url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_linux_hotspot_21.0.11_10.tar.gz" + [[tools.lychee]] -version = "0.23.0" +version = "0.24.2" backend = "aqua:lycheeverse/lychee" [tools.lychee."platforms.linux-arm64"] -checksum = "sha256:97eb93b02a7d78a752fc33e5b0983439ccaadbf3db952b68a0a4401acd92e6e0" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:91a7bd65685da41b90ccb9bc867a3d649a7818042dae04ff405e55a25bddee4c" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-gnu.tar.gz" [tools.lychee."platforms.linux-arm64-musl"] -checksum = "sha256:97eb93b02a7d78a752fc33e5b0983439ccaadbf3db952b68a0a4401acd92e6e0" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:91a7bd65685da41b90ccb9bc867a3d649a7818042dae04ff405e55a25bddee4c" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-gnu.tar.gz" [tools.lychee."platforms.linux-x64"] -checksum = "sha256:5538440d2c69a45a0a09983271e5dee0c2fe7137d8035d25b2632e10a66a090a" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:73657a111819a30c47c08352896796f23d64e4eb2b3ed39b6d32149241566fc5" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz" [tools.lychee."platforms.linux-x64-musl"] -checksum = "sha256:5538440d2c69a45a0a09983271e5dee0c2fe7137d8035d25b2632e10a66a090a" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:73657a111819a30c47c08352896796f23d64e4eb2b3ed39b6d32149241566fc5" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz" + +[[tools.prettier]] +version = "3.8.4" +backend = "npm:prettier" + +[[tools.yamllint]] +version = "1.38.0" +backend = "pipx:yamllint" + +[[tools.yq]] +version = "4.53.3" +backend = "aqua:mikefarah/yq" + +[tools.yq."platforms.linux-arm64"] +checksum = "sha256:578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_arm64" + +[tools.yq."platforms.linux-arm64-musl"] +checksum = "sha256:578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_arm64" + +[tools.yq."platforms.linux-x64"] +checksum = "sha256:fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64" + +[tools.yq."platforms.linux-x64-musl"] +checksum = "sha256:fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64" + +[tools.yq."platforms.macos-arm64"] +checksum = "sha256:877de31753a4dd2401aa048937aa9a7fc4d5f6ce858cf31508c5802954297213" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_darwin_arm64" -[tools.lychee."platforms.macos-arm64"] -checksum = "sha256:4c8034900e11083b68ac6f6582c377ff1f704e268991999e09d717973e493e7f" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-arm64-macos.dmg" +[tools.yq."platforms.macos-x64"] +checksum = "sha256:b4ba1ecce3c47f00803f4f964de38394326c7a32eb6540616e04fb2935a0f08d" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_darwin_amd64" -[tools.lychee."platforms.windows-x64"] -checksum = "sha256:0fda7ff0a60c0250939fc25361c2d4e6e7853c31c996733fdd5a1dd760bcb824" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-windows.exe" +[tools.yq."platforms.windows-x64"] +checksum = "sha256:e279bc506a452eeafcdf364f91a025455e402a8001169083caf01f4b64a544e2" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_windows_amd64.exe"