diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 74805c066..45bfbd91a 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -82,7 +82,7 @@ jobs: instrumentation_test: runs-on: ubuntu-latest name: Run Android Instrumentation Tests - timeout-minutes: 30 + timeout-minutes: 50 steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -116,5 +116,7 @@ jobs: adb shell settings put global window_animation_scale 0.0 adb shell settings put global transition_animation_scale 0.0 adb shell settings put global animator_duration_scale 0.0 - sleep 5 + adb shell pm list packages >/dev/null + sleep 10 + adb shell getprop sys.boot_completed ./gradlew connectedCoreDebugAndroidTest --stacktrace diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4bef3f2f..61301c780 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ plugins { id("io.gitlab.arturbosch.detekt") id("com.google.devtools.ksp") version "2.0.0-1.0.22" apply true id("de.mannodermaus.android-junit5") version "1.11.2.0" - id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" id("jacoco") kotlin("plugin.serialization") version "1.9.0" id("org.jetbrains.kotlinx.kover") version "0.6.1" @@ -27,6 +27,7 @@ jacoco { toolVersion = "0.8.12" } +val kotlinVersion by extra("2.0.0") val junit5Version by extra("5.11.2") val mockkVersion by extra("1.13.13") @@ -44,6 +45,16 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + packaging { + resources { + pickFirsts.add("META-INF/LICENSE*") + pickFirsts.add("META-INF/ASL2.0") + pickFirsts.add("META-INF/NOTICE*") + pickFirsts.add("META-INF/LGPL2.1") + + } + } + kover { verify { @@ -65,12 +76,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } buildFeatures { @@ -111,6 +122,9 @@ android { sourceSets { getByName("main").java.srcDirs("src/main/kotlin") + named("test") { + java.srcDirs("src/test/java", "src/test/kotlin") + } } lint { @@ -202,7 +216,7 @@ dependencies { // ========================== // Kotlin // ========================== - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21") // ========================== // Layout and UI @@ -245,22 +259,27 @@ dependencies { // ========================== // Unit Testing // ========================== - - testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") testImplementation("io.mockk:mockk:$mockkVersion") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("androidx.compose.ui:ui-test-junit4:1.8.3") + testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.20") + testImplementation ("org.mockito:mockito-core:5.12.0") + testImplementation ("org.mockito:mockito-inline:5.2.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") + testImplementation("com.google.truth:truth:1.4.4") - // ========================== - // Instrumentation Tests - // ========================== - androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.8.3") - debugImplementation("androidx.compose.ui:ui-test-manifest:1.8.3") // ========================== // UI Tests // ========================== + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.8.3") + androidTestImplementation("io.mockk:mockk-android:$mockkVersion") + androidTestImplementation("com.google.truth:truth:1.4.4") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.8.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") // ========================== @@ -271,21 +290,20 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:runner:1.6.2") - androidTestImplementation("io.mockk:mockk-android:1.13.13") + androidTestImplementation("io.mockk:mockk-android:1.14.2") // ========================== // Other // ========================== - - api("joda-time:joda-time:2.12.7") + api("joda-time:joda-time:2.14.0") api("com.github.tibbi:RecyclerView-FastScroller:e7d3e150c4") api("com.github.tibbi:reprint:2cb206415d") api("androidx.core:core-ktx:1.16.0") - api("com.google.code.gson:gson:2.11.0") + api("com.google.code.gson:gson:2.13.1") api("com.github.bumptech.glide:glide:4.16.0") - ksp("com.github.bumptech.glide:ksp:4.14.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + ksp("com.github.bumptech.glide:ksp:4.16.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") } tasks.register("moveFromi18n") { @@ -329,9 +347,6 @@ tasks.withType(Test::class) { } } -tasks.withType { - useJUnitPlatform() -} tasks.register("jacocoTestReport") { group = "Reporting" diff --git a/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt b/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt new file mode 100644 index 000000000..e8a9956d9 --- /dev/null +++ b/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.screens.about + +import android.content.Intent +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey +import androidx.test.espresso.intent.matcher.IntentMatchers.hasType +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import be.scri.R +import be.scri.activities.MainActivity +import be.scri.ui.models.ScribeItem +import com.google.common.truth.Truth.assertThat +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.Matchers.`is` +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for verifying the behavior of [AboutUtil] functions within an Android context. + * + * This class tests that various utility functions used in the About screen: + * - Trigger appropriate Intents + * - Handle context properly + * - Return expected list data + * - Execute callbacks correctly + * + * It uses Espresso Intents, ActivityScenario, and Compose test utilities. + */ +@RunWith(AndroidJUnit4::class) +class AboutUtilInstrumentedTest { + @get:Rule + val composeTestRule = createComposeRule() + + /** + * Initializes Espresso Intents before each test. + */ + @Before + fun setup() { + Intents.init() + } + + /** + * Releases Espresso Intents after each test to avoid leaks. + */ + @After + fun tearDown() { + Intents.release() + } + + /** + * Verifies that [AboutUtil.onShareScribeClick] does not crash when called + * and launches a share chooser intent from an Activity context. + */ + @Test + fun test_onShareScribeClick_doesNotCrash() { + // Use the app context. + val scenario = ActivityScenario.launch(MainActivity::class.java) + scenario.onActivity { activity -> + AboutUtil.onShareScribeClick(activity) + } + + Intents.intended(hasAction(Intent.ACTION_CHOOSER)) + } + + /** + * Verifies that [AboutUtil.onRateScribeClick] executes without crashing + * when invoked from an Activity context. + */ + @Test + fun test_onRateScribeClick_doesNotCrash() { + val scenario = ActivityScenario.launch(MainActivity::class.java) + scenario.onActivity { activity -> + AboutUtil.onRateScribeClick(activity) + } + } + + /** + * Asserts that [AboutUtil.onMailClick] launches an email intent wrapped in a chooser. + */ + @Test + fun test_onMailClick_launchesEmailIntent() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + AboutUtil.onMailClick(context) + + Intents.intended( + allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtraWithKey(Intent.EXTRA_INTENT), + hasExtra( + `is`(Intent.EXTRA_INTENT), + allOf( + hasAction(Intent.ACTION_SEND), + hasType("message/rfc822"), + ), + ), + ), + ) + } + + /** + * Tests [AboutUtil.getCommunityList] returns a valid list and + * triggers correct callbacks for Share and Wikimedia items. + */ + @Test + fun testGetCommunityList() { + println("Testing getCommunityList...") + + var wikimediaClicked = false + var shareClicked = false + val context = InstrumentationRegistry.getInstrumentation().targetContext + + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + val communityList = + AboutUtil.getCommunityList( + onWikimediaAndScribeClick = { wikimediaClicked = true }, + onShareScribeClick = { shareClicked = true }, + context = context, + ) + + // Test list is not empty. + assertThat(communityList.items).isNotEmpty() + assertThat(communityList.items).hasSize(5) + + // Test each item has required fields. + communityList.items.forEach { item -> + assertThat(item).isInstanceOf(ScribeItem.ExternalLinkItem::class.java) + val linkItem = item as ScribeItem.ExternalLinkItem + + assertThat(linkItem.leadingIcon).isNotNull() + assertThat(linkItem.title).isNotNull() + assertThat(linkItem.trailingIcon).isNotNull() + assertThat(linkItem.onClick).isNotNull() + } + + // Test specific items. + val githubItem = communityList.items[0] as ScribeItem.ExternalLinkItem + assertThat(githubItem.leadingIcon).isEqualTo(R.drawable.github_logo) + assertThat(githubItem.title).isEqualTo(R.string.app_about_community_github) + + val shareItem = communityList.items[3] as ScribeItem.ExternalLinkItem + assertThat(shareItem.leadingIcon).isEqualTo(R.drawable.share_icon) + assertThat(shareItem.title).isEqualTo(R.string.app_about_community_share_scribe) + + val wikimediaItem = communityList.items[4] as ScribeItem.ExternalLinkItem + assertThat(wikimediaItem.leadingIcon).isEqualTo(R.drawable.wikimedia_logo_black) + assertThat(wikimediaItem.title).isEqualTo(R.string.app_about_community_wikimedia) + + // Test onClick callbacks. + shareItem.onClick() + wikimediaItem.onClick() + } + } + + // Verify callbacks were triggered. + assertThat(shareClicked).isTrue() + assertThat(wikimediaClicked).isTrue() + + println("getCommunityList test passed!") + } + + /** + * Tests [AboutUtil.getFeedbackAndSupportList] returns a valid list and + * executes all related click callbacks correctly. + */ + @Test + fun testGetFeedbackAndSupportList() { + println("Testing getFeedbackAndSupportList...") + + var rateClicked = false + var mailClicked = false + var resetHintsClicked = false + val context = InstrumentationRegistry.getInstrumentation().targetContext + + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + val feedbackList = + AboutUtil.getFeedbackAndSupportList( + onRateScribeClick = { rateClicked = true }, + onMailClick = { mailClicked = true }, + onResetHintsClick = { resetHintsClicked = true }, + context = context, + ) + + // Test list is not empty. + assertThat(feedbackList.items).isNotEmpty() + assertThat(feedbackList.items).hasSize(5) + + // Test each item has required fields. + feedbackList.items.forEach { item -> + assertThat(item).isInstanceOf(ScribeItem.ExternalLinkItem::class.java) + val linkItem = item as ScribeItem.ExternalLinkItem + + assertThat(linkItem.leadingIcon).isNotNull() + assertThat(linkItem.title).isNotNull() + assertThat(linkItem.trailingIcon).isNotNull() + assertThat(linkItem.onClick).isNotNull() + } + + // Test specific items. + val rateItem = feedbackList.items[0] as ScribeItem.ExternalLinkItem + assertThat(rateItem.leadingIcon).isEqualTo(R.drawable.star) + assertThat(rateItem.title).isEqualTo(R.string.app_about_feedback_rate_scribe) + + val mailItem = feedbackList.items[2] as ScribeItem.ExternalLinkItem + assertThat(mailItem.leadingIcon).isEqualTo(R.drawable.mail_icon) + assertThat(mailItem.title).isEqualTo(R.string.app_about_feedback_email) + + val hintsItem = feedbackList.items[4] as ScribeItem.ExternalLinkItem + assertThat(hintsItem.leadingIcon).isEqualTo(R.drawable.light_bulb_icon) + assertThat(hintsItem.title).isEqualTo(R.string.app_about_feedback_app_hints) + + // Test onClick callbacks. + rateItem.onClick() + mailItem.onClick() + hintsItem.onClick() + } + } + + // Verify callbacks were triggered. + assertThat(rateClicked).isTrue() + assertThat(mailClicked).isTrue() + assertThat(resetHintsClicked).isTrue() + + println("getFeedbackAndSupportList test passed!") + } + + /** + * Tests [AboutUtil.getLegalListItems] returns legal items and triggers + * callbacks for privacy policy and licenses. + */ + @Test + fun testGetLegalListItems() { + println("Testing getLegalListItems...") + + var privacyPolicyClicked = false + var thirdPartyLicensesClicked = false + val context = InstrumentationRegistry.getInstrumentation().targetContext + + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + val legalList = + AboutUtil.getLegalListItems( + onPrivacyPolicyClick = { privacyPolicyClicked = true }, + onThirdPartyLicensesClick = { thirdPartyLicensesClicked = true }, + ) + + // Test list is not empty. + assertThat(legalList.items).isNotEmpty() + assertThat(legalList.items).hasSize(2) + + // Test each item has required fields. + legalList.items.forEach { item -> + assertThat(item).isInstanceOf(ScribeItem.ExternalLinkItem::class.java) + val linkItem = item as ScribeItem.ExternalLinkItem + + assertThat(linkItem.leadingIcon).isNotNull() + assertThat(linkItem.title).isNotNull() + assertThat(linkItem.trailingIcon).isNotNull() + assertThat(linkItem.onClick).isNotNull() + } + + // Test specific items. + val privacyItem = legalList.items[0] as ScribeItem.ExternalLinkItem + assertThat(privacyItem.leadingIcon).isEqualTo(R.drawable.shield_lock) + assertThat(privacyItem.title).isEqualTo(R.string.app_about_legal_privacy_policy) + assertThat(privacyItem.trailingIcon).isEqualTo(R.drawable.right_arrow) + + val licenseItem = legalList.items[1] as ScribeItem.ExternalLinkItem + assertThat(licenseItem.leadingIcon).isEqualTo(R.drawable.license_icon) + assertThat(licenseItem.title).isEqualTo(R.string.app_about_legal_third_party) + assertThat(licenseItem.trailingIcon).isEqualTo(R.drawable.right_arrow) + + // Test onClick callbacks. + privacyItem.onClick() + licenseItem.onClick() + } + } + + // Verify callbacks were triggered. + assertThat(privacyPolicyClicked).isTrue() + assertThat(thirdPartyLicensesClicked).isTrue() + + println("getLegalListItems test passed!") + } + + /** + * Asserts that all [ExternalLinks] constants are correctly defined and non-empty. + */ + @Test + fun testExternalLinksConstants() { + println("Testing ExternalLinks constants...") + + // Test that external links are properly defined. + assertThat(be.scri.ui.screens.about.ExternalLinks.GITHUB_SCRIBE).isNotEmpty() + assertThat(be.scri.ui.screens.about.ExternalLinks.GITHUB_ISSUES).isNotEmpty() + assertThat(be.scri.ui.screens.about.ExternalLinks.GITHUB_RELEASES).isNotEmpty() + assertThat(be.scri.ui.screens.about.ExternalLinks.MATRIX).isNotEmpty() + assertThat(be.scri.ui.screens.about.ExternalLinks.MASTODON).isNotEmpty() + + println("ExternalLinks constants test passed!") + } +} diff --git a/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt b/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt index d1d60471a..5847669e4 100644 --- a/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt +++ b/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package be.scri.helpers.ui +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.util.Log -import androidx.core.content.ContextCompat /** * A helper to facilitate sharing of the application and contacting the team. @@ -21,20 +21,25 @@ object ShareHelper { */ fun shareScribe(context: Context) { try { - val sharingIntent = + val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://github.com/scribe-org/Scribe-Android") + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } - ContextCompat.startActivity( - context, - Intent.createChooser(sharingIntent, "Share via"), - null, - ) + + val chooser = Intent.createChooser(intent, "Share via") + if (context !is Activity) { + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(chooser) } catch (e: ActivityNotFoundException) { - Log.e("AboutFragment", "No application found to share content", e) + Log.e("ShareHelper", "No application found to share content", e) } catch (e: IllegalArgumentException) { - Log.e("AboutFragment", "Invalid argument for sharing", e) + Log.e("ShareHelper", "Invalid argument for sharing", e) } } @@ -48,6 +53,7 @@ object ShareHelper { * @throws ActivityNotFoundException If no email client is installed. * @throws IllegalArgumentException If there's an issue with the email intent arguments. */ + fun sendEmail(context: Context) { try { val intent = @@ -55,16 +61,21 @@ object ShareHelper { type = "message/rfc822" putExtra(Intent.EXTRA_EMAIL, arrayOf("team@scri.be")) putExtra(Intent.EXTRA_SUBJECT, "Hey Scribe!") + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } - ContextCompat.startActivity( - context, - Intent.createChooser(intent, "Choose an Email client:"), - null, - ) + + val chooser = Intent.createChooser(intent, "Choose an Email client:") + if (context !is Activity) { + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(chooser) } catch (e: ActivityNotFoundException) { - Log.e("AboutFragment", "No email client found", e) + Log.e("ShareHelper", "No email client found", e) } catch (e: IllegalArgumentException) { - Log.e("AboutFragment", "Invalid argument for sending email", e) + Log.e("ShareHelper", "Invalid argument for sending email", e) } } } diff --git a/app/src/test/kotlin/be/scri/helpers/PreferencesHelperTest.kt b/app/src/test/kotlin/be/scri/helpers/PreferencesHelperTest.kt index 7b96ec8b8..96933c7db 100644 --- a/app/src/test/kotlin/be/scri/helpers/PreferencesHelperTest.kt +++ b/app/src/test/kotlin/be/scri/helpers/PreferencesHelperTest.kt @@ -6,8 +6,8 @@ package be.scri.helpers -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test class PreferencesHelperTest { @Test diff --git a/app/src/test/kotlin/helpers/ComprehensiveCoverageTest.kt b/app/src/test/kotlin/helpers/ComprehensiveCoverageTest.kt index 266c64f2a..f3bce1d7a 100644 --- a/app/src/test/kotlin/helpers/ComprehensiveCoverageTest.kt +++ b/app/src/test/kotlin/helpers/ComprehensiveCoverageTest.kt @@ -7,7 +7,7 @@ import android.provider.SyncStateContract.Constants import androidx.test.ext.junit.runners.AndroidJUnit4 import be.scri.activities.MainActivity import be.scri.helpers.AlphanumericComparator -import org.junit.Test +import org.junit.jupiter.api.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class)