diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b52903651..de541cc44 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,7 +51,7 @@ android { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64") } isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") // If signing.properties file not found, gradle will build an unsigned APK. // For release builds, provide the required "signingPropsFilePath" for a signed APK, using: // ./gradlew assembleRelease -PsigningPropsFilePath=absolute-file-path/signing.properties diff --git a/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/ScenarioRuleScreenshotTest.kt b/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/ScenarioRuleScreenshotTest.kt new file mode 100644 index 000000000..bf8b6afa5 --- /dev/null +++ b/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/ScenarioRuleScreenshotTest.kt @@ -0,0 +1,48 @@ +/* Copyright 2025 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.esri.arcgismaps.kotlin.sampleviewer + +import androidx.test.core.graphics.writeToTestStorage +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.captureToBitmap +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestName +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScenarioRuleScreenshotTest { + + @get:Rule + val scenarioRule = activityScenarioRule() + + @get:Rule + val testName = TestName() + + @Test + fun launch_idle_and_save_screenshot() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + onView(isRoot()) + .perform(captureToBitmap { bmp -> + bmp.writeToTestStorage("Launcher_${testName.methodName}") + }) + } +} diff --git a/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/UIAutomatorScreenshotTest.kt b/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/UIAutomatorScreenshotTest.kt new file mode 100644 index 000000000..bbcac5a94 --- /dev/null +++ b/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/UIAutomatorScreenshotTest.kt @@ -0,0 +1,84 @@ +/* Copyright 2025 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.esri.arcgismaps.kotlin.sampleviewer + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Run with: ./gradlew connectedAndroidTest + */ +@RunWith(AndroidJUnit4::class) +class UIAutomatorScreenshotTest { + + private lateinit var device: UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.pressHome() + device.waitForIdle() + + // Launch the app + val context = ApplicationProvider.getApplicationContext() + val intent = context.packageManager.getLaunchIntentForPackage( + "com.esri.arcgismaps.kotlin.sampleviewer" + )?.apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + + context.startActivity(intent) + + // Wait for app to launch + device.wait( + Until.hasObject(By.pkg("com.esri.arcgismaps.kotlin.sampleviewer")), + 10000 + ) + device.waitForIdle() + } + + + @Test + fun testHomeScreenScreenshot() = runBlocking { + val screenshotFile = getScreenshotFile("HomeScreen").apply { + parentFile?.mkdirs() + } + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + assertTrue(device.takeScreenshot(screenshotFile)) + } + + private fun getScreenshotFile(fileName: String): File { + val picturesDir = android.os.Environment.getExternalStoragePublicDirectory( + android.os.Environment.DIRECTORY_PICTURES + ) + val screenshotDir = File(picturesDir, "SampleViewerScreenshots") + screenshotDir.mkdirs() + return File(screenshotDir, "${System.currentTimeMillis()}_${fileName}.png") + } +} diff --git a/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepositoryAndroidTest.kt b/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepositoryAndroidTest.kt new file mode 100644 index 000000000..bef0dceed --- /dev/null +++ b/app/src/androidTest/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepositoryAndroidTest.kt @@ -0,0 +1,63 @@ +/* Copyright 2025 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.esri.arcgismaps.kotlin.sampleviewer.model + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SampleInfoRepositoryTest { + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Given the generated assets in 'samples/samples.json', + * when the DefaultSampleInfoRepository is loaded, + * then the count of samples emitted by getAllSamples() + * should match the JSON sample count. + */ + @Test + fun verifySampleCount() = runBlocking { + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + // Read the generated asset directly and count top-level entries. + val samplesJsonString = context.assets.open("samples/samples.json").bufferedReader().use { it.readText() } + val parsed = json.decodeFromString>>(samplesJsonString) + val jsonCount = parsed.size + + // Load the repository from the generated assets. + DefaultSampleInfoRepository.load(context) + + // Wait for the repository flow to be populated. + // The StateFlow emits an initial empty list, so we wait until it emits the expected count. + val sampleViewerCount = withTimeout(30_000) { + DefaultSampleInfoRepository.getAllSamples() + .first { samples -> samples.size >= jsonCount } + .size + } + + assertEquals(jsonCount, sampleViewerCount) + } +} diff --git a/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepository.kt b/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepository.kt index 6ca44f0cc..db25d88f2 100644 --- a/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepository.kt +++ b/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/DefaultSampleInfoRepository.kt @@ -126,4 +126,11 @@ object DefaultSampleInfoRepository : SampleInfoRepository { override fun getSamplesInCategory(sampleCategory: SampleCategory): Flow> { return sampleData.map { it.filter { sample -> sample.metadata.sampleCategory == sampleCategory } } } + + /** + * Get all samples from the repository. + */ + override fun getAllSamples(): Flow> { + return sampleData + } } diff --git a/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/SampleInfoRepository.kt b/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/SampleInfoRepository.kt index d7ca8e15a..d5c9e1a2a 100644 --- a/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/SampleInfoRepository.kt +++ b/app/src/main/java/com/esri/arcgismaps/kotlin/sampleviewer/model/SampleInfoRepository.kt @@ -26,4 +26,6 @@ interface SampleInfoRepository { fun getSamplesInCategory(sampleCategory: SampleCategory): Flow> fun getSampleByName(sampleName: String): Flow + + fun getAllSamples(): Flow> } diff --git a/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt index 06dc52b38..b06260bca 100644 --- a/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt @@ -1,5 +1,6 @@ import com.android.build.api.dsl.ApplicationExtension import com.esri.arcgismaps.kotlin.build_logic.convention.configureAndroidCompose +import com.esri.arcgismaps.kotlin.build_logic.convention.configureAndroidComposeTests import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.getByType @@ -14,6 +15,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin { } val extension = extensions.getByType() configureAndroidCompose(extension) + configureAndroidComposeTests(extension) } } } diff --git a/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt index f370d3f0c..ccb4bd0c9 100644 --- a/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt @@ -2,12 +2,10 @@ import com.android.build.gradle.LibraryExtension import com.esri.arcgismaps.kotlin.build_logic.convention.configureKotlinAndroid import com.esri.arcgismaps.kotlin.build_logic.convention.implementation import com.esri.arcgismaps.kotlin.build_logic.convention.libs -import com.esri.arcgismaps.kotlin.build_logic.convention.testImplementation import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies -import org.gradle.kotlin.dsl.kotlin class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { @@ -21,7 +19,6 @@ class AndroidLibraryConventionPlugin : Plugin { configureKotlinAndroid(this) compileSdk = libs.findVersion("targetSdk").get().toString().toInt() defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } @@ -47,7 +44,6 @@ class AndroidLibraryConventionPlugin : Plugin { } dependencies { - testImplementation(kotlin("test")) // External libraries implementation(libs.findLibrary("androidx-constraintlayout").get()) implementation(libs.findLibrary("androidx-appcompat").get()) diff --git a/build-logic/convention/src/main/java/com/esri/arcgismaps/kotlin/build_logic/convention/AndroidCompose.kt b/build-logic/convention/src/main/java/com/esri/arcgismaps/kotlin/build_logic/convention/AndroidCompose.kt index be21e4159..a218a3dc9 100644 --- a/build-logic/convention/src/main/java/com/esri/arcgismaps/kotlin/build_logic/convention/AndroidCompose.kt +++ b/build-logic/convention/src/main/java/com/esri/arcgismaps/kotlin/build_logic/convention/AndroidCompose.kt @@ -22,16 +22,34 @@ internal fun Project.configureAndroidCompose( dependencies { val composeBom = libs.findLibrary("androidx-compose-bom").get() implementation(platform(composeBom)) - androidTestImplementation(platform(composeBom)) implementation(libs.findLibrary("androidx-activity-compose").get()) implementation(libs.findLibrary("androidx-compose-material3").get()) implementation(libs.findLibrary("androidx-lifecycle-viewmodel-compose").get()) implementation(libs.findLibrary("androidx-compose-ui-tooling-preview").get()) + implementation(libs.findLibrary("androidx-concurrent-futures").get()) + implementation(libs.findLibrary("androidx-concurrent-futures-ktx").get()) debugImplementation(libs.findLibrary("androidx-compose-ui-tooling").get()) - debugImplementation(libs.findLibrary("androidx-compose-ui-test-manifest").get()) - androidTestImplementation(libs.findLibrary("androidx-compose-ui-test").get()) - androidTestImplementation(libs.findLibrary("androidx-compose-ui-test-junit4").get()) + } + } +} +internal fun Project.configureAndroidComposeTests( + commonExtension: CommonExtension<*, *, *, *, *, *>, +) { + commonExtension.apply { + dependencies { + val composeBom = libs.findLibrary("androidx-compose-bom").get() + androidTestImplementation(platform(composeBom)) + androidTestImplementation(libs.findLibrary("androidx-compose-ui-test-junit4").get()) + androidTestImplementation(libs.findLibrary("androidx-test-uiautomator").get()) + androidTestImplementation(libs.findLibrary("androidx-test-runner").get()) + androidTestImplementation(libs.findLibrary("androidx-test-rules").get()) + androidTestImplementation(libs.findLibrary("androidx-test-ext-junit-ktx").get()) + androidTestImplementation(libs.findLibrary("androidx-junit").get()) + androidTestImplementation(libs.findLibrary("androidx-espresso-core").get()) + androidTestImplementation(libs.findLibrary("kotlinx-coroutines-test").get()) + debugImplementation(libs.findLibrary("androidx-compose-ui-test-manifest").get()) + debugImplementation(libs.findLibrary("junit").get()) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 062b9051b..bcf591ccb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,13 @@ materialIconsExt = "1.7.8" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +uiautomator = "2.3.0" +testRunner = "1.7.0" +testRules = "1.7.0" +testExtJunit = "1.3.0" +androidxConcurrent = "1.3.0" +androidxConcurrentKtx = "1.3.0" +coroutinesTest = "1.10.2" ### Application Verions versionCode = "3000000" @@ -73,7 +80,6 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } -androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } @@ -100,6 +106,13 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "testRules" } +androidx-test-ext-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "testExtJunit" } +androidx-concurrent-futures = { group = "androidx.concurrent", name = "concurrent-futures", version.ref = "androidxConcurrent" } +androidx-concurrent-futures-ktx = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version.ref = "androidxConcurrentKtx" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }