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 index e8a9956d..37d3cd60 100644 --- a/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/be/scri/ui/screens/about/AboutUtilInstrumentedTest.kt @@ -131,7 +131,7 @@ class AboutUtilInstrumentedTest { // Test list is not empty. assertThat(communityList.items).isNotEmpty() - assertThat(communityList.items).hasSize(5) + assertThat(communityList.items).hasSize(3) // Test each item has required fields. communityList.items.forEach { item -> @@ -149,11 +149,11 @@ class AboutUtilInstrumentedTest { 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 + val shareItem = communityList.items[1] 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 + val wikimediaItem = communityList.items[2] as ScribeItem.ExternalLinkItem assertThat(wikimediaItem.leadingIcon).isEqualTo(R.drawable.wikimedia_logo_black) assertThat(wikimediaItem.title).isEqualTo(R.string.app_about_community_wikimedia) 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 5847669e..51bd3f68 100644 --- a/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt +++ b/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt @@ -7,6 +7,34 @@ import android.content.Context import android.content.Intent import android.util.Log +/** Interface for sharing functionality. */ +interface ShareHelperInterface { + /** + * Shares scribe. + * @param context The Android context used to perform the share action. + */ + fun shareScribe(context: Context) +} + +/** Implementation of ShareHelperInterface to handle sharing a scribe. */ +class ShareHelperImpl : ShareHelperInterface { + /** + * Shares a text message about Scribe using Android's share intent. + * + * @param context The context from which to launch the share intent. + */ + override fun shareScribe(context: Context) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "Check out Scribe!") + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, "Share Scribe via…") + context.startActivity(shareIntent) + } +} + /** * A helper to facilitate sharing of the application and contacting the team. */ diff --git a/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt b/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt index c6cee266..ed09bd38 100644 --- a/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt +++ b/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt @@ -11,12 +11,12 @@ import be.scri.R import be.scri.activities.MainActivity import be.scri.helpers.ui.RatingHelper import be.scri.helpers.ui.ShareHelper +import be.scri.helpers.ui.ShareHelperImpl +import be.scri.helpers.ui.ShareHelperInterface import be.scri.ui.models.ScribeItem import be.scri.ui.models.ScribeItemList -/** - * A centralized object that stores all external URLs used in the About screen. - */ +/** A centralized object that stores all external URLs used in the About screen. */ object ExternalLinks { const val GITHUB_SCRIBE = "https://github.com/scribe-org/Scribe-Android" const val GITHUB_ISSUES = "$GITHUB_SCRIBE/issues" @@ -25,15 +25,162 @@ object ExternalLinks { const val MASTODON = "https://wikis.world/@scribe" } -/** This file provide utility functions for the about page */ +/** + * Builds a list of community-related external link items displayed on the About screen. + * + * @param context Context to launch intents for opening URLs. + * @param onShareScribeClick Callback invoked when the "Share Scribe" item is clicked. + * @param onWikimediaAndScribeClick Callback invoked when the Wikimedia item is clicked. + * @return A list of [ScribeItem.ExternalLinkItem] representing community links and actions. + */ +fun buildCommunityList( + context: Context, + onShareScribeClick: () -> Unit, + onWikimediaAndScribeClick: () -> Unit, +): List = + listOf( + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.github_logo, + title = R.string.app_about_community_github, + trailingIcon = R.drawable.external_link, + url = ExternalLinks.GITHUB_SCRIBE, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.GITHUB_SCRIBE)) + context.startActivity(intent) + }, + ), + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.share_icon, + title = R.string.app_about_community_share_scribe, + trailingIcon = R.drawable.external_link, + url = null, + onClick = { onShareScribeClick() }, + ), + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.wikimedia_logo_black, + title = R.string.app_about_community_wikimedia, + trailingIcon = R.drawable.right_arrow, + url = null, + onClick = { onWikimediaAndScribeClick() }, + ), + ) + +/** + * Returns a list of legal-related item specifications such as Privacy Policy and Third-Party Licenses. + * + * @return A list of [LegalItemSpec] with legal info metadata. + */ +fun getLegalItemSpecs(): List = + listOf( + LegalItemSpec( + icon = R.drawable.shield_lock, + title = R.string.app_about_legal_privacy_policy, + destination = Destination.PrivacyPolicy, + ), + LegalItemSpec( + icon = R.drawable.license_icon, + title = R.string.app_about_legal_third_party, + destination = Destination.ThirdPartyLicenses, + ), + ) + +/** + * Builds a list of feedback and support-related external link items for the About screen. + * + * @param context Context to launch intents. + * @param onRateScribeClick Callback invoked when user selects "Rate Scribe". + * @param onMailClick Callback invoked when user wants to send feedback email. + * @param onResetHintsClick Callback invoked to reset onboarding hints. + * @return A list of [ScribeItem.ExternalLinkItem] for feedback and support options. + */ +fun feedbackAndSupportList( + context: Context, + onRateScribeClick: () -> Unit, + onMailClick: () -> Unit, + onResetHintsClick: () -> Unit, +): List = + listOf( + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.star, + title = R.string.app_about_feedback_rate_scribe, + trailingIcon = R.drawable.external_link, + url = null, + onClick = { onRateScribeClick() }, + ), + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.bug_report_icon, + title = R.string.app_about_feedback_bug_report, + trailingIcon = R.drawable.external_link, + url = ExternalLinks.GITHUB_ISSUES, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.GITHUB_ISSUES)) + context.startActivity(intent) + }, + ), + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.mail_icon, + title = R.string.app_about_feedback_email, + trailingIcon = R.drawable.external_link, + url = null, + onClick = { onMailClick() }, + ), + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.bookmark_icon, + title = R.string.app_about_feedback_version, + trailingIcon = R.drawable.external_link, + url = ExternalLinks.GITHUB_RELEASES, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ExternalLinks.GITHUB_RELEASES)) + context.startActivity(intent) + }, + ), + ScribeItem.ExternalLinkItem( + leadingIcon = R.drawable.light_bulb_icon, + title = R.string.app_about_feedback_app_hints, + trailingIcon = R.drawable.counter_clockwise_icon, + url = null, + onClick = { onResetHintsClick() }, + ), + ) + +/** + * Data class representing legal item specification metadata. + * + * @property icon Resource ID of the icon displayed. + * @property title Resource ID of the title string. + * @property destination The destination screen or action associated with this item. + */ +data class LegalItemSpec( + val icon: Int, + val title: Int, + val destination: Destination, +) + +/** + * Enum representing navigation destinations for legal items. + */ +enum class Destination { + PrivacyPolicy, + ThirdPartyLicenses, +} + +/** + * Utility object providing helper functions and data for the About screen. + */ object AboutUtil { + /** + * Instance of [ShareHelperInterface] used for sharing actions. + * Initialized with the concrete implementation [ShareHelperImpl]. + */ + var shareHelper: ShareHelperInterface = ShareHelperImpl() + /** * Shares the Scribe app via the system's share sheet. * * @param context Context used to launch the sharing intent. */ fun onShareScribeClick(context: Context) { - ShareHelper.shareScribe(context) + shareHelper.shareScribe(context) } /** @@ -55,13 +202,12 @@ object AboutUtil { } /** - * Returns a list of community links and actions for the About screen, such as GitHub, Matrix, - * or Mastodon. The list is memoized with [remember]. + * Returns a memoized list of community links for the About screen. * - * @param onWikimediaAndScribeClick Callback invoked when the Wikimedia item is clicked. - * @param onShareScribeClick Callback for the share Scribe action. - * @param context Android context used to open URLs. - * @return A [ScribeItemList] representing community-related actions. + * @param onWikimediaAndScribeClick Callback invoked when Wikimedia link is clicked. + * @param onShareScribeClick Callback invoked when Share Scribe link is clicked. + * @param context Android context to open URLs. + * @return A [ScribeItemList] wrapping community external links. */ @Composable fun getCommunityList( @@ -71,83 +217,18 @@ object AboutUtil { ): ScribeItemList = remember { ScribeItemList( - items = - listOf( - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.github_logo, - title = R.string.app_about_community_github, - trailingIcon = R.drawable.external_link, - url = ExternalLinks.GITHUB_SCRIBE, - onClick = { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - ExternalLinks.GITHUB_SCRIBE, - ), - ) - context.startActivity(intent) - }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.matrix_icon, - title = R.string.app_about_community_matrix, - trailingIcon = R.drawable.external_link, - url = - ExternalLinks.MATRIX, - onClick = { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - ExternalLinks.MATRIX, - ), - ) - context.startActivity(intent) - }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.mastodon_svg_icon, - title = R.string.app_about_community_mastodon, - trailingIcon = R.drawable.external_link, - url = ExternalLinks.MASTODON, - onClick = { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - ExternalLinks.MASTODON, - ), - ) - context.startActivity(intent) - }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.share_icon, - title = R.string.app_about_community_share_scribe, - trailingIcon = R.drawable.external_link, - url = null, - onClick = { onShareScribeClick() }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.wikimedia_logo_black, - title = R.string.app_about_community_wikimedia, - trailingIcon = R.drawable.right_arrow, - url = null, - onClick = { onWikimediaAndScribeClick() }, - ), - ), + items = buildCommunityList(context, onShareScribeClick, onWikimediaAndScribeClick), ) } /** - * Returns a list of feedback and support options for the About screen. + * Returns a memoized list of feedback and support items for the About screen. * - * @param onRateScribeClick Callback for initiating app rating. - * @param onMailClick Callback to open an email intent. + * @param onRateScribeClick Callback for "Rate Scribe" action. + * @param onMailClick Callback to open email intent. * @param onResetHintsClick Callback to reset onboarding hints. - * @param context Android context used to open external URLs. - * @return A [ScribeItemList] with support-related options. + * @param context Android context used to launch external intents. + * @return A [ScribeItemList] wrapping feedback and support options. */ @Composable fun getFeedbackAndSupportList( @@ -159,72 +240,21 @@ object AboutUtil { remember { ScribeItemList( items = - listOf( - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.star, - title = R.string.app_about_feedback_rate_scribe, - trailingIcon = R.drawable.external_link, - url = null, - onClick = { onRateScribeClick() }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.bug_report_icon, - title = R.string.app_about_feedback_bug_report, - trailingIcon = R.drawable.external_link, - url = ExternalLinks.GITHUB_ISSUES, - onClick = { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - ExternalLinks.GITHUB_ISSUES, - ), - ) - context.startActivity(intent) - }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.mail_icon, - title = R.string.app_about_feedback_email, - trailingIcon = R.drawable.external_link, - url = null, - onClick = { onMailClick() }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.bookmark_icon, - title = R.string.app_about_feedback_version, - // , BuildConfig.VERSION_NAME, - trailingIcon = R.drawable.external_link, - url = - ExternalLinks.GITHUB_RELEASES, - onClick = { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - ExternalLinks.GITHUB_RELEASES, - ), - ) - context.startActivity(intent) - }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.light_bulb_icon, - title = R.string.app_about_feedback_app_hints, - trailingIcon = R.drawable.counter_clockwise_icon, - url = null, - onClick = { onResetHintsClick() }, - ), + feedbackAndSupportList( + context, + onRateScribeClick, + onMailClick, + onResetHintsClick, ), ) } /** - * Returns a list of legal-related items like privacy policy and licenses. + * Returns a memoized list of legal items for the About screen. * - * @param onPrivacyPolicyClick Callback invoked when the Privacy Policy is selected. - * @param onThirdPartyLicensesClick Callback for opening the licenses screen. - * @return A [ScribeItemList] with legal information items. + * @param onPrivacyPolicyClick Callback invoked when Privacy Policy is selected. + * @param onThirdPartyLicensesClick Callback invoked when Third-Party Licenses is selected. + * @return A [ScribeItemList] wrapping legal information items. */ @Composable fun getLegalListItems( @@ -232,24 +262,23 @@ object AboutUtil { onThirdPartyLicensesClick: () -> Unit, ): ScribeItemList = remember { - ScribeItemList( - items = - listOf( - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.shield_lock, - title = R.string.app_about_legal_privacy_policy, - trailingIcon = R.drawable.right_arrow, - url = null, - onClick = { onPrivacyPolicyClick() }, - ), - ScribeItem.ExternalLinkItem( - leadingIcon = R.drawable.license_icon, - title = R.string.app_about_legal_third_party, - trailingIcon = R.drawable.right_arrow, - url = null, - onClick = { onThirdPartyLicensesClick() }, - ), - ), - ) + val items = + getLegalItemSpecs().map { spec -> + val clickHandler = + when (spec.destination) { + Destination.PrivacyPolicy -> onPrivacyPolicyClick + Destination.ThirdPartyLicenses -> onThirdPartyLicensesClick + } + + ScribeItem.ExternalLinkItem( + leadingIcon = spec.icon, + title = spec.title, + trailingIcon = R.drawable.right_arrow, + url = null, + onClick = clickHandler, + ) + } + + ScribeItemList(items = items) } } diff --git a/app/src/test/kotlin/be/scri/ui/screens/about/AboutUtilTest.kt b/app/src/test/kotlin/be/scri/ui/screens/about/AboutUtilTest.kt new file mode 100644 index 00000000..9695b8e3 --- /dev/null +++ b/app/src/test/kotlin/be/scri/ui/screens/about/AboutUtilTest.kt @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.ui.screens.about + +import android.content.Context +import android.content.Intent +import be.scri.R +import be.scri.activities.MainActivity +import be.scri.helpers.ui.HintUtils +import be.scri.helpers.ui.RatingHelper +import be.scri.helpers.ui.ShareHelper +import be.scri.helpers.ui.ShareHelperInterface +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AboutUtilTest { + private lateinit var context: Context + + @BeforeEach + fun setUp() { + context = mockk(relaxed = true) + } + + @Test + fun getCommunityList_callsOnShareScribeClick_whenShareItemClicked() { + val mockContext = mockk(relaxed = true) + every { mockContext.getString(any()) } returns "Mocked String" + + var called = false + + println("Before building list") + val list = + buildCommunityList( + context = mockContext, + onShareScribeClick = { called = true }, + onWikimediaAndScribeClick = {}, + ) + + println("Community list size: ${list.size}") + + assertTrue(list.size >= 3, "Expected at least 3 items") + list[1].onClick() + } + + @Test + fun testGetFeedbackAndSupportList_correctItemGeneration() { + val mockContext = mockk(relaxed = true) + + every { mockContext.getString(any()) } returns "Mocked String" + every { mockContext.packageName } returns "be.scri" + every { mockContext.startActivity(any()) } just Runs + + var rateClicked = false + var mailClicked = false + var resetHintsClicked = false + + val list = + feedbackAndSupportList( + context = mockContext, + onRateScribeClick = { rateClicked = true }, + onMailClick = { mailClicked = true }, + onResetHintsClick = { resetHintsClicked = true }, + ) + + assertEquals(5, list.size) + + // Checking the resource IDs are preserved, not string values. + assertEquals(R.string.app_about_feedback_rate_scribe, list[0].title) + assertEquals(R.string.app_about_feedback_bug_report, list[1].title) + assertEquals(R.string.app_about_feedback_email, list[2].title) + assertEquals(R.string.app_about_feedback_version, list[3].title) + assertEquals(R.string.app_about_feedback_app_hints, list[4].title) + + list[0].onClick() + assertTrue(rateClicked) + + list[2].onClick() + assertTrue(mailClicked) + + list[4].onClick() + assertTrue(resetHintsClicked) + + list[1].onClick() + list[3].onClick() + + verify(exactly = 2) { mockContext.startActivity(any()) } + } + + @Test + fun `getLegalItemSpecs returns expected list`() { + val specs = getLegalItemSpecs() + + assertEquals(2, specs.size) + assertEquals(R.drawable.shield_lock, specs[0].icon) + assertEquals(R.string.app_about_legal_privacy_policy, specs[0].title) + assertEquals(Destination.PrivacyPolicy, specs[0].destination) + + assertEquals(R.drawable.license_icon, specs[1].icon) + assertEquals(R.string.app_about_legal_third_party, specs[1].title) + assertEquals(Destination.ThirdPartyLicenses, specs[1].destination) + } + + @Test + fun testOnShareScribeClick() { + // Arrange + val mockHelper = mockk(relaxed = true) + AboutUtil.shareHelper = mockHelper + + // Act + AboutUtil.onShareScribeClick(context) + + // Assert + verify { mockHelper.shareScribe(context) } + } + + @Test + fun testOnRateScribeClick() { + // Arrange + val mockActivity = mockk(relaxed = true) + mockkObject(RatingHelper) + every { RatingHelper.rateScribe(any(), any()) } just Runs + + // Act + AboutUtil.onRateScribeClick(mockActivity) + + // Assert + verify { RatingHelper.rateScribe(mockActivity, mockActivity) } + } + + @Test + fun testOnMailClick() { + // Arrange + mockkObject(ShareHelper) + every { ShareHelper.sendEmail(any()) } just Runs // Mock the method to do nothing + + // Act + AboutUtil.onMailClick(context) + + // Assert + verify { ShareHelper.sendEmail(context) } + } + + @Test + fun `onResetHintsClick calls HintUtils resetHints`() { + val mockContext = mockk(relaxed = true) + mockkObject(HintUtils) + + every { HintUtils.resetHints(mockContext) } returns Unit + + var called = false + val list = + feedbackAndSupportList( + context = mockContext, + onRateScribeClick = {}, + onMailClick = {}, + onResetHintsClick = { + called = true + HintUtils.resetHints(mockContext) + }, + ) + + list[4].onClick() + + verify(exactly = 1) { HintUtils.resetHints(mockContext) } + assertTrue(called) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 6d1168e4..0f870a09 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ buildscript { dependencies { classpath("io.nlopez.compose.rules:ktlint:0.4.17") - classpath("com.android.tools.build:gradle:8.9.3") + classpath("com.android.tools.build:gradle:8.7.0") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0") classpath("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.8") classpath("org.jlleitschuh.gradle:ktlint-gradle:12.1.1")