diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7743014f79..c02c2655b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,9 +27,6 @@ android { resourceConfigurations += listOf("ar", "be", "bg", "bn", "bn-rIN", "bs", "cs", "da", "de", "el-rGR", "en", "eo", "es", "es-rAR", "et", "fi", "fr", "gl", "he-rIL", "hi", "hr", "hu", "in-rID", "is", "it", "ja", "ko", "lt", "lv", "nb-rNO", "nl", "oc", "pl", "pt", "pt-rBR", "pt-rPT", "ro-rRO", "ru", "sk", "sl", "sr", "sv", "ta", "tr", "uk", "vi", "zh-rCN", "zh-rTW") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("boolean", "showDonate", "true") - buildConfigField("boolean", "showRateOnGooglePlay", "false") } buildTypes { @@ -130,6 +127,7 @@ dependencies { implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui-tooling-preview-android") + debugImplementation("androidx.compose.ui:ui-tooling:1.9.2") // Third-party implementation("com.journeyapps:zxing-android-embedded:4.3.0@aar") @@ -148,10 +146,16 @@ dependencies { androidTestImplementation("androidx.test:core:$androidXTestVersion") androidTestImplementation("junit:junit:$junitVersion") - androidTestImplementation("androidx.test.ext:junit:1.2.1") +// androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test:runner:$androidXTestVersion") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") + implementation("androidx.test.espresso:espresso-intents:3.7.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + + // Test rules and transitive dependencies: + implementation("androidx.compose.ui:ui-test-junit4:1.9.2") +// Needed for createComposeRule(), but not for createAndroidComposeRule(): + debugImplementation("androidx.compose.ui:ui-test-manifest:1.9.2") } tasks.register("copyRawResFiles", Copy::class) { diff --git a/app/src/androidTest/java/protect/card_locker/AboutActivityIntegrationTest.kt b/app/src/androidTest/java/protect/card_locker/AboutActivityIntegrationTest.kt new file mode 100644 index 0000000000..004e8fb381 --- /dev/null +++ b/app/src/androidTest/java/protect/card_locker/AboutActivityIntegrationTest.kt @@ -0,0 +1,240 @@ +package protect.card_locker + +import android.content.Context +import android.content.Intent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.Matchers.allOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AboutActivityIntegrationTest { + private lateinit var context: Context + @get:Rule + val composableTestRule = createAndroidComposeRule() + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun testActivityCreation() { + val title = context.getString(R.string.about_title_fmt, context.getString(R.string.app_name)) + + // Ensure the UI is ready before checking the nodes + composableTestRule.waitForIdle() + + with(composableTestRule) { + // Check title exists + onNodeWithText(title).assertExists() + + // Check key elements are initialized + onNodeWithText(context.getString(R.string.credits)).assertExists() + onNodeWithText(context.getString(R.string.version_history)).assertExists() + onNodeWithText(context.getString(R.string.help_translate_this_app)).assertExists() + onNodeWithText(context.getString(R.string.license)).assertExists() + onNodeWithText(context.getString(R.string.source_repository)).assertExists() + onNodeWithText(context.getString(R.string.privacy_policy)).assertExists() + onNodeWithText(context.getString(R.string.report_error)).assertExists() + } + } + + + @Test + fun testDialogContentMethods() { + // Use reflection to test private methods + with(composableTestRule) { + onNodeWithTag(context.getString(R.string.license)) + .performClick() + + onNodeWithText(context.getString(R.string.ok)) + .assertIsDisplayed() + .performClick() + + onNodeWithText(context.getString(R.string.ok)) + .assertIsNotDisplayed() + } + } + + @Test + fun testClickListeners() { + // need to catch the Intent. This requires Espresso-Intents + Intents.init() + + with(composableTestRule) { + onNodeWithTag(context.getString(R.string.source_repository)) + .performClick() + + waitForIdle() + } + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.REPOSITORY_SOURCE) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserOnVersionHistoryUrl() { + Intents.init() + + with(composableTestRule) { + onNodeWithTag(context.getString(R.string.version_history)) + .performClick() + + onNodeWithText(context.getString(R.string.view_online)) + .assertIsDisplayed() + .performClick() + + waitForIdle() + } + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.VERSION_HISTORY) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserWhenClickOnHelpTranslateThisApp() { + Intents.init() + + with(composableTestRule) { + onNodeWithTag(context.getString(R.string.help_translate_this_app)) + .performClick() + + waitForIdle() + } + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.HELP_TRANSLATE_APP) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserWhenClickOnLicense() { + Intents.init() + + with(composableTestRule) { + onNodeWithTag(context.getString(R.string.license)) + .performClick() + + onNodeWithText(context.getString(R.string.view_online)) + .assertIsDisplayed() + .performClick() + } + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.LICENSE) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserWhenClickOnSourceRepository() { + Intents.init() + + composableTestRule + .onNodeWithTag(context.getString(R.string.source_repository)) + .performClick() + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.REPOSITORY_SOURCE) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserWhenClickOnPrivacyPolicy() { + Intents.init() + + with(composableTestRule) { + onNodeWithTag(context.getString(R.string.privacy_policy)) + .performClick() + + onNodeWithText(context.getString(R.string.view_online)) + .assertIsDisplayed() + .performClick() + } + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.PRIVACY_POLICY) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserWhenClickOnDonate() { + Intents.init() + + composableTestRule + .onNodeWithTag(context.getString(R.string.donate)) + .performClick() + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.DONATE) + ) + ) + + Intents.release() + } + + @Test + fun testOpenBrowserWhenClickOnReportError() { + Intents.init() + + composableTestRule + .onNodeWithTag(context.getString(R.string.report_error)) + .performClick() + + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(AppURLs.REPORT_ERROR) + ) + ) + + Intents.release() + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 57d875c67f..25c56a2e16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -204,4 +204,18 @@ + + + + + + + + + diff --git a/app/src/main/java/protect/card_locker/AboutActivity.kt b/app/src/main/java/protect/card_locker/AboutActivity.kt index d6ecfada27..bbeaf072e8 100644 --- a/app/src/main/java/protect/card_locker/AboutActivity.kt +++ b/app/src/main/java/protect/card_locker/AboutActivity.kt @@ -3,153 +3,37 @@ package protect.card_locker import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme - -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.fromHtml -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview - -import protect.card_locker.compose.CatimaAboutSection -import protect.card_locker.compose.CatimaTopAppBar -import protect.card_locker.compose.theme.CatimaTheme - +import protect.card_locker.compose.AboutScreenRoot + +object AppURLs { + const val VERSION_HISTORY = "https://catima.app/changelog" + const val HELP_TRANSLATE_APP = "https://hosted.weblate.org/engage/catima" + const val LICENSE = "https://github.com/CatimaLoyalty/Android/blob/main/LICENSE" + const val REPOSITORY_SOURCE = "https://github.com/CatimaLoyalty/Android" + const val PRIVACY_POLICY = "https://catima.app/privacy-policy" + const val DONATE = "https://catima.app/donate" + const val RATE_THE_APP = "https://play.google.com/store/apps/details?id=me.hackerchick.catima" + const val REPORT_ERROR = "https://github.com/CatimaLoyalty/Android/issues" +} class AboutActivity : ComponentActivity() { - private lateinit var content: AboutContent @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - content = AboutContent(this) - title = content.pageTitle + val screenTitle = AboutContent.getPageTitle(this) + title = screenTitle setContent { - ScreenContent( - showDonate = BuildConfig.showDonate, - showRateOnGooglePlay = BuildConfig.showRateOnGooglePlay + AboutScreenRoot( + title = screenTitle, + showDonate = !BuildConfig.DEBUG, // show donate button only in release builds + showRateOnGooglePlay = !BuildConfig.DEBUG, // show rate button only in release builds + onBackPressedDispatcher = onBackPressedDispatcher, ) } } +} - @Composable - fun ScreenContent( - showDonate: Boolean, - showRateOnGooglePlay: Boolean - ) { - CatimaTheme { - Scaffold( - topBar = { CatimaTopAppBar(title.toString(), onBackPressedDispatcher) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState()) - ) { - CatimaAboutSection( - stringResource(R.string.version_history), - content.versionHistory, - onClickUrl = "https://catima.app/changelog/", - onClickDialogText = AnnotatedString.fromHtml( - htmlString = content.historyHtml, - linkStyles = TextLinkStyles( - style = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary - ) - ) - ) - ) - CatimaAboutSection( - stringResource(R.string.credits), - content.copyrightShort, - onClickDialogText = AnnotatedString.fromHtml( - htmlString = content.contributorInfoHtml, - linkStyles = TextLinkStyles( - style = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary - ) - ) - ) - ) - CatimaAboutSection( - stringResource(R.string.help_translate_this_app), - stringResource(R.string.translate_platform), - onClickUrl = "https://hosted.weblate.org/engage/catima/" - ) - CatimaAboutSection( - stringResource(R.string.license), - stringResource(R.string.app_license), - onClickUrl = "https://github.com/CatimaLoyalty/Android/blob/main/LICENSE", - onClickDialogText = AnnotatedString.fromHtml( - htmlString = content.licenseHtml, - linkStyles = TextLinkStyles( - style = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary - ) - ) - ) - ) - CatimaAboutSection( - stringResource(R.string.source_repository), - stringResource(R.string.on_github), - onClickUrl = "https://github.com/CatimaLoyalty/Android/" - ) - CatimaAboutSection( - stringResource(R.string.privacy_policy), - stringResource(R.string.and_data_usage), - onClickUrl = "https://catima.app/privacy-policy/", - onClickDialogText = AnnotatedString.fromHtml( - htmlString = content.privacyHtml, - linkStyles = TextLinkStyles( - style = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary - ) - ) - ) - ) - if (showDonate) { - CatimaAboutSection( - stringResource(R.string.donate), - "", - onClickUrl = "https://catima.app/donate" - ) - } - if (showRateOnGooglePlay) { - CatimaAboutSection( - stringResource(R.string.rate_this_app), - stringResource(R.string.on_google_play), - onClickUrl = "https://play.google.com/store/apps/details?id=me.hackerchick.catima" - ) - } - CatimaAboutSection( - stringResource(R.string.report_error), - stringResource(R.string.on_github), - onClickUrl = "https://github.com/CatimaLoyalty/Android/issues" - ) - } - } - } - } - @Preview - @Composable - fun AboutActivityPreview() { - ScreenContent( - showDonate = true, - showRateOnGooglePlay = true - ) - } -} diff --git a/app/src/main/java/protect/card_locker/AboutContent.java b/app/src/main/java/protect/card_locker/AboutContent.java deleted file mode 100644 index e1d7ead702..0000000000 --- a/app/src/main/java/protect/card_locker/AboutContent.java +++ /dev/null @@ -1,147 +0,0 @@ -package protect.card_locker; - -import android.content.Context; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.util.Log; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; - -public class AboutContent { - - public static final String TAG = "Catima"; - - public Context context; - - public AboutContent(Context context) { - this.context = context; - } - - public void destroy() { - this.context = null; - } - - public String getPageTitle() { - return String.format(context.getString(R.string.about_title_fmt), context.getString(R.string.app_name)); - } - - public String getAppVersion() { - String version = "?"; - try { - PackageInfo pi = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); - version = pi.versionName; - } catch (PackageManager.NameNotFoundException e) { - Log.w(TAG, "Package name not found", e); - } - - return version; - } - - public int getCurrentYear() { - return Calendar.getInstance().get(Calendar.YEAR); - } - - public String getCopyright() { - return String.format(context.getString(R.string.app_copyright_fmt), getCurrentYear()); - } - - public String getCopyrightShort() { - return context.getString(R.string.app_copyright_short); - } - - public String getContributorsHtml() { - String contributors; - try { - contributors = "
" + Utils.readTextFile(context, R.raw.contributors); - } catch (IOException ignored) { - return ""; - } - return contributors.replace("\n", "
"); - } - - public String getHistoryHtml() { - String versionHistory; - try { - versionHistory = Utils.readTextFile(context, R.raw.changelog) - .replace("# Changelog\n\n", ""); - } catch (IOException ignored) { - return ""; - } - return Utils.linkify(Utils.basicMDToHTML(versionHistory)) - .replace("\n", "
"); - } - - public String getLicenseHtml() { - try { - return Utils.readTextFile(context, R.raw.license); - } catch (IOException ignored) { - return ""; - } - } - - public String getPrivacyHtml() { - String privacyPolicy; - try { - privacyPolicy = Utils.readTextFile(context, R.raw.privacy) - .replace("# Privacy Policy\n", ""); - } catch (IOException ignored) { - return ""; - } - return Utils.linkify(Utils.basicMDToHTML(privacyPolicy)) - .replace("\n", "
"); - } - - public String getThirdPartyLibrariesHtml() { - final List usedLibraries = new ArrayList<>(); - usedLibraries.add(new ThirdPartyInfo("Color Picker", "https://github.com/jaredrummler/ColorPicker", "Apache 2.0")); - usedLibraries.add(new ThirdPartyInfo("Commons CSV", "https://commons.apache.org/proper/commons-csv/", "Apache 2.0")); - usedLibraries.add(new ThirdPartyInfo("NumberPickerPreference", "https://github.com/invissvenska/NumberPickerPreference", "GNU LGPL 3.0")); - usedLibraries.add(new ThirdPartyInfo("uCrop", "https://github.com/Yalantis/uCrop", "Apache 2.0")); - usedLibraries.add(new ThirdPartyInfo("Zip4j", "https://github.com/srikanth-lingala/zip4j", "Apache 2.0")); - usedLibraries.add(new ThirdPartyInfo("ZXing", "https://github.com/zxing/zxing", "Apache 2.0")); - usedLibraries.add(new ThirdPartyInfo("ZXing Android Embedded", "https://github.com/journeyapps/zxing-android-embedded", "Apache 2.0")); - - StringBuilder result = new StringBuilder("
"); - for (ThirdPartyInfo entry : usedLibraries) { - result.append("
") - .append(entry.toHtml()); - } - - return result.toString(); - } - - public String getUsedThirdPartyAssetsHtml() { - final List usedAssets = new ArrayList<>(); - usedAssets.add(new ThirdPartyInfo("Android icons", "https://fonts.google.com/icons?selected=Material+Icons", "Apache 2.0")); - - StringBuilder result = new StringBuilder().append("
"); - for (ThirdPartyInfo entry : usedAssets) { - result.append("
") - .append(entry.toHtml()); - } - - return result.toString(); - } - - public String getContributorInfoHtml() { - StringBuilder contributorInfo = new StringBuilder(); - contributorInfo.append(getCopyright()); - contributorInfo.append("

"); - contributorInfo.append(context.getString(R.string.app_copyright_old)); - contributorInfo.append("

"); - contributorInfo.append(String.format(context.getString(R.string.app_contributors), getContributorsHtml())); - contributorInfo.append("

"); - contributorInfo.append(String.format(context.getString(R.string.app_libraries), getThirdPartyLibrariesHtml())); - contributorInfo.append("

"); - contributorInfo.append(String.format(context.getString(R.string.app_resources), getUsedThirdPartyAssetsHtml())); - - return contributorInfo.toString(); - } - - public String getVersionHistory() { - return String.format(context.getString(R.string.debug_version_fmt), getAppVersion()); - } -} diff --git a/app/src/main/java/protect/card_locker/AboutContent.kt b/app/src/main/java/protect/card_locker/AboutContent.kt new file mode 100644 index 0000000000..cbd2428451 --- /dev/null +++ b/app/src/main/java/protect/card_locker/AboutContent.kt @@ -0,0 +1,192 @@ +package protect.card_locker + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import java.io.IOException +import java.util.Calendar + +object AboutContent { + const val TAG: String = "Catima" + + fun getPageTitle(context: Context): String { + return String.format( + context.getString(R.string.about_title_fmt), + context.getString(R.string.app_name) + ) + } + + fun getAppVersion(context: Activity): String? { + var version: String? = "?" + try { + val pi = context.packageManager.getPackageInfo(context.packageName, 0) + version = pi.versionName + } catch (e: PackageManager.NameNotFoundException) { + Log.w(TAG, "Package name not found", e) + } + + return version + } + + val currentYear: Int + get() = Calendar.getInstance().get(Calendar.YEAR) + + fun getCopyright(context: Context): String { + return String.format( + context.getString(R.string.app_copyright_fmt), + this.currentYear + ) + } + + fun getCopyrightShort(context: Context): String { + return context.getString(R.string.app_copyright_short) + } + + fun getContributorsHtml(context: Context): String { + val contributors: String? + try { + contributors = "
" + Utils.readTextFile(context, R.raw.contributors) + } catch (_: IOException) { + return "" + } + return contributors.replace("\n", "
") + } + + fun getHistoryHtml(context: Context): String { + val versionHistory: String? + try { + versionHistory = Utils.readTextFile(context, R.raw.changelog) + .replace("# Changelog\n\n", "") + } catch (_: IOException) { + return "" + } + return Utils.linkify(Utils.basicMDToHTML(versionHistory)) + .replace("\n", "
") + } + + fun getLicenseHtml(context: Context): String { + return try { + Utils.readTextFile(context, R.raw.license) + } catch (_: IOException) { + "" + } + } + + fun getPrivacyHtml(context: Context): String { + val privacyPolicy: String? + try { + privacyPolicy = Utils.readTextFile(context, R.raw.privacy) + .replace("# Privacy Policy\n", "") + } catch (_: IOException) { + return "" + } + return Utils.linkify(Utils.basicMDToHTML(privacyPolicy)) + .replace("\n", "
") + } + + val thirdPartyLibrariesHtml: String + get() { + val usedLibraries: MutableList = + ArrayList() + usedLibraries.add( + ThirdPartyInfo( + "Color Picker", + "https://github.com/jaredrummler/ColorPicker", + "Apache 2.0" + ) + ) + usedLibraries.add( + ThirdPartyInfo( + "Commons CSV", + "https://commons.apache.org/proper/commons-csv/", + "Apache 2.0" + ) + ) + usedLibraries.add( + ThirdPartyInfo( + "NumberPickerPreference", + "https://github.com/invissvenska/NumberPickerPreference", + "GNU LGPL 3.0" + ) + ) + usedLibraries.add( + ThirdPartyInfo( + "uCrop", + "https://github.com/Yalantis/uCrop", + "Apache 2.0" + ) + ) + usedLibraries.add( + ThirdPartyInfo( + "Zip4j", + "https://github.com/srikanth-lingala/zip4j", + "Apache 2.0" + ) + ) + usedLibraries.add( + ThirdPartyInfo( + "ZXing", + "https://github.com/zxing/zxing", + "Apache 2.0" + ) + ) + usedLibraries.add( + ThirdPartyInfo( + "ZXing Android Embedded", + "https://github.com/journeyapps/zxing-android-embedded", + "Apache 2.0" + ) + ) + + val result = StringBuilder("
") + for (entry in usedLibraries) { + result.append("
") + .append(entry.toHtml()) + } + + return result.toString() + } + + val usedThirdPartyAssetsHtml: String + get() { + val usedAssets: MutableList = ArrayList() + usedAssets.add( + ThirdPartyInfo( + "Android icons", + "https://fonts.google.com/icons?selected=Material+Icons", + "Apache 2.0" + ) + ) + + val result = StringBuilder().append("
") + for (entry in usedAssets) { + result.append("
") + .append(entry.toHtml()) + } + + return result.toString() + } + + fun getContributorInfoHtml(context: Context): String { + return getCopyright(context) + + "

" + + context.getString(R.string.app_copyright_old) + + "

" + String.format( + context.getString(R.string.app_contributors), + getContributorsHtml(context) + ) + + "

" + String.format( + context.getString(R.string.app_libraries), + this.thirdPartyLibrariesHtml + ) + + "

" + String.format( + context.getString(R.string.app_resources), + this.usedThirdPartyAssetsHtml + ) + } + + fun getVersionHistory(context: Activity): String { + return String.format(context.getString(R.string.debug_version_fmt), getAppVersion(context)) + } +} diff --git a/app/src/main/java/protect/card_locker/OpenWebLinkHandler.java b/app/src/main/java/protect/card_locker/OpenWebLinkHandler.java deleted file mode 100644 index 8bfcef4f65..0000000000 --- a/app/src/main/java/protect/card_locker/OpenWebLinkHandler.java +++ /dev/null @@ -1,28 +0,0 @@ -package protect.card_locker; - -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.net.Uri; -import android.util.Log; -import android.widget.Toast; - -public class OpenWebLinkHandler { - - private static final String TAG = "Catima"; - - public void openBrowser(Activity activity, String url) { - if (url == null) { - return; - } - - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse(url)); - try { - activity.startActivity(intent); - } catch (ActivityNotFoundException e) { - Toast.makeText(activity, R.string.failedToOpenUrl, Toast.LENGTH_LONG).show(); - Log.e(TAG, "No activity found to handle intent", e); - } - } -} diff --git a/app/src/main/java/protect/card_locker/OpenWebLinkHandler.kt b/app/src/main/java/protect/card_locker/OpenWebLinkHandler.kt new file mode 100644 index 0000000000..2ff4b59364 --- /dev/null +++ b/app/src/main/java/protect/card_locker/OpenWebLinkHandler.kt @@ -0,0 +1,37 @@ +package protect.card_locker + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.widget.Toast +import androidx.core.net.toUri + +object OpenWebLinkHandler { + fun openURL(activity: Activity, url: String) { + if (url.isBlank()) { + Toast.makeText(activity, "Invalid URL", Toast.LENGTH_SHORT).show() + return + } + + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = url.trim().toUri() + // Ensure it opens in browser, not your own app + addCategory(Intent.CATEGORY_BROWSABLE) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + // Check if there is any activity that can handle this intent + if (intent.resolveActivity(activity.packageManager) != null) { + activity.startActivity(intent) + } else { + Toast.makeText(activity, "No app found to open URL", Toast.LENGTH_SHORT).show() + } + + } catch (_: ActivityNotFoundException) { + Toast.makeText(activity, "No application found to open link", Toast.LENGTH_SHORT).show() + } catch (_: Exception) { + Toast.makeText(activity, "Failed to open link", Toast.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/java/protect/card_locker/compose/AboutActivity.kt b/app/src/main/java/protect/card_locker/compose/AboutActivity.kt index 555b766905..73c709a79e 100644 --- a/app/src/main/java/protect/card_locker/compose/AboutActivity.kt +++ b/app/src/main/java/protect/card_locker/compose/AboutActivity.kt @@ -1,59 +1,248 @@ package protect.card_locker.compose +import androidx.activity.OnBackPressedDispatcher import androidx.activity.compose.LocalActivity import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import protect.card_locker.AboutContent +import protect.card_locker.AppURLs import protect.card_locker.OpenWebLinkHandler import protect.card_locker.R +import protect.card_locker.compose.theme.CatimaTheme + @Composable -fun CatimaAboutSection(title: String, message: String, onClickUrl: String? = null, onClickDialogText: AnnotatedString? = null) { - val activity = LocalActivity.current +fun AboutScreenRoot( + title: String, + showDonate: Boolean, + showRateOnGooglePlay: Boolean, + onBackPressedDispatcher: OnBackPressedDispatcher? = null +) { + CatimaTheme { + Scaffold( + topBar = { + CatimaTopAppBar(title, onBackPressedDispatcher) + } + ) { innerPadding -> + AboutScreenContent( + modifier = Modifier.padding(innerPadding), + showDonate = showDonate, + showRateOnGooglePlay = showRateOnGooglePlay, + ) + } + } +} + - val openDialog = remember { mutableStateOf(false) } +@Composable +fun AboutScreenContent( + modifier: Modifier = Modifier, + showDonate: Boolean, + showRateOnGooglePlay: Boolean, +) { + val context = LocalContext.current + val activity = LocalActivity.current Column( - modifier = Modifier - .padding(8.dp) - .clickable { - if (onClickDialogText != null) { - openDialog.value = true - } else if (onClickUrl != null) { - OpenWebLinkHandler().openBrowser(activity, onClickUrl) + modifier = modifier.verticalScroll(rememberScrollState()) + ) { + CatimaAboutSection( + title = stringResource(R.string.version_history), + message = if (activity == null) "" else AboutContent.getVersionHistory(activity), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.VERSION_HISTORY) + } + }, + onClickDialogText = AnnotatedString.fromHtml( + htmlString = AboutContent.getHistoryHtml(context), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ) + ) + ) + ) + HorizontalDivider() + CatimaAboutSection( + title = stringResource(R.string.credits), + message = AboutContent.getCopyrightShort(context), + onClickDialogText = AnnotatedString.fromHtml( + htmlString = AboutContent.getContributorInfoHtml(context), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ) + ) + ) + ) + HorizontalDivider() + CatimaAboutSection( + title = stringResource(R.string.help_translate_this_app), + message = stringResource(R.string.translate_platform), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.HELP_TRANSLATE_APP) } } - ) { - Row { - Column(modifier = Modifier.weight(1F)) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge + ) + HorizontalDivider() + CatimaAboutSection( + title = stringResource(R.string.license), + message = stringResource(R.string.app_license), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.LICENSE) + } + }, + onClickDialogText = AnnotatedString.fromHtml( + htmlString = AboutContent.getLicenseHtml(context), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ) ) - Text(text = message) + ) + ) + HorizontalDivider() + CatimaAboutSection( + title = stringResource(R.string.source_repository), + message = stringResource(R.string.on_github), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.REPOSITORY_SOURCE) + } + }, + ) + HorizontalDivider() + CatimaAboutSection( + title = stringResource(R.string.privacy_policy), + message = stringResource(R.string.and_data_usage), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.PRIVACY_POLICY) + } + }, + onClickDialogText = AnnotatedString.fromHtml( + htmlString = AboutContent.getPrivacyHtml(context), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ) + ) + ), + ) + HorizontalDivider() + if (showDonate) { + CatimaAboutSection( + title = stringResource(R.string.donate), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.DONATE) + } + }, + ) + HorizontalDivider() + } + if (showRateOnGooglePlay) { + CatimaAboutSection( + title = stringResource(R.string.rate_this_app), + message = stringResource(R.string.on_google_play), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.RATE_THE_APP) + } + }, + ) + HorizontalDivider() + } + CatimaAboutSection( + title = stringResource(R.string.report_error), + message = stringResource(R.string.on_github), + openURL = { + activity?.let { + OpenWebLinkHandler.openURL(it, AppURLs.REPORT_ERROR) + } + }, + ) + } +} + +@Composable +fun CatimaAboutSection( + modifier: Modifier = Modifier, + title: String, + message: String? = null, + onClickDialogText: AnnotatedString? = null, + openURL: (() -> Unit)? = null, +) { + var openDialog by remember { mutableStateOf(false) } + + Row( + modifier = modifier + .testTag(title) + .heightIn(min = 60.dp) + .clickable { + when { + onClickDialogText != null -> openDialog = true + + openURL != null -> { + openDialog = false + openURL() + } + } } - Text(modifier = Modifier.align(Alignment.CenterVertically), - text = ">", - style = MaterialTheme.typography.titleMedium + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1F)) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge ) + if (!message.isNullOrEmpty()) Text(text = message) } + Icon( + Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + ) } - if (openDialog.value && onClickDialogText != null) { + if (openDialog && onClickDialogText != null) { AlertDialog( icon = {}, title = { @@ -62,25 +251,27 @@ fun CatimaAboutSection(title: String, message: String, onClickUrl: String? = nul text = { Text( text = onClickDialogText, - modifier = Modifier.verticalScroll(rememberScrollState())) + modifier = Modifier.verticalScroll(rememberScrollState()) + ) }, onDismissRequest = { - openDialog.value = false + openDialog = false }, confirmButton = { TextButton( onClick = { - openDialog.value = false + openDialog = false } ) { Text(stringResource(R.string.ok)) } }, - dismissButton = { - if (onClickUrl != null) { + dismissButton = if (openURL == null) null else { + { TextButton( onClick = { - OpenWebLinkHandler().openBrowser(activity, onClickUrl) + openDialog = false + openURL() } ) { Text(stringResource(R.string.view_online)) @@ -89,4 +280,13 @@ fun CatimaAboutSection(title: String, message: String, onClickUrl: String? = nul } ) } +} + +@Preview(showBackground = true) +@Composable +fun AboutActivityPreview() { + AboutScreenContent( + showDonate = true, + showRateOnGooglePlay = true, + ) } \ No newline at end of file diff --git a/app/src/test/java/protect/card_locker/AboutActivityTest.kt b/app/src/test/java/protect/card_locker/AboutActivityTest.kt index 002f6e009b..3cfe601f85 100644 --- a/app/src/test/java/protect/card_locker/AboutActivityTest.kt +++ b/app/src/test/java/protect/card_locker/AboutActivityTest.kt @@ -1,78 +1,72 @@ package protect.card_locker -import android.content.Intent -import android.net.Uri -import android.view.View -import android.widget.TextView -import androidx.core.view.isVisible -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse +import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.shadows.ShadowActivity -import org.robolectric.shadows.ShadowLog -import java.lang.reflect.Method +import org.robolectric.android.controller.ActivityController +import protect.card_locker.compose.AboutScreenRoot -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class AboutActivityTest { - private lateinit var activityController: org.robolectric.android.controller.ActivityController + private lateinit var context: Context + private lateinit var activityController: ActivityController private lateinit var activity: AboutActivity - private lateinit var shadowActivity: ShadowActivity + @Before fun setUp() { - ShadowLog.stream = System.out + // Get the application context + context = ApplicationProvider.getApplicationContext() + + // Build the activity using Robolectric but don't create/start it yet activityController = Robolectric.buildActivity(AboutActivity::class.java) activity = activityController.get() - shadowActivity = shadowOf(activity) } - @Test - fun testActivityCreation() { - activityController.create().start().resume() - - // Verify activity title is set correctly - assertEquals(activity.title.toString(), - activity.getString(R.string.about_title_fmt, activity.getString(R.string.app_name))) - - // Check key elements are initialized - assertNotNull(activity.findViewById(R.id.toolbar)) - assertNotNull(activity.findViewById(R.id.credits_sub)) - assertNotNull(activity.findViewById(R.id.version_history_sub)) - } + @get:Rule + val composableTestRule = createComposeRule() @Test - fun testDisplayOptionsBasedOnConfig() { - activityController.create().start().resume() - - // Test Google Play rate button visibility based on BuildConfig - val rateButton = activity.findViewById(R.id.rate) - assertEquals(BuildConfig.showRateOnGooglePlay, rateButton.isVisible) + fun testDisplayDonateOptionWhenTrueInConfig() { + with(composableTestRule) { + setContent { + AboutScreenRoot( + title = "Test", + showDonate = true, + showRateOnGooglePlay = false, + ) + } - // Test donate button visibility based on BuildConfig - val donateButton = activity.findViewById(R.id.donate) - assertEquals(BuildConfig.showDonate, donateButton.isVisible) + onNodeWithText(context.getString(R.string.rate_this_app)).assertDoesNotExist() + onNodeWithText(context.getString(R.string.donate)).assertExists() + } } @Test - fun testClickListeners() { - activityController.create().start().resume() - - // Test clicking on a link that opens external browser - val repoButton = activity.findViewById(R.id.repo) - repoButton.performClick() + fun testDisplayRateAppOptionWhenTrueInConfig() { + with(composableTestRule) { + setContent { + AboutScreenRoot( + title = "Test", + showDonate = false, + showRateOnGooglePlay = true, + ) + } - val startedIntent = shadowActivity.nextStartedActivity - assertEquals(Intent.ACTION_VIEW, startedIntent.action) - assertEquals(Uri.parse("https://github.com/CatimaLoyalty/Android/"), - startedIntent.data) + onNodeWithText(context.getString(R.string.rate_this_app)).assertExists() + onNodeWithText(context.getString(R.string.donate)).assertDoesNotExist() + } } @Test @@ -80,92 +74,11 @@ class AboutActivityTest { activityController.create().start().resume() // Verify a view exists before destruction - assertNotNull(activity.findViewById(R.id.credits_sub)) + composableTestRule.onNodeWithTag(context.getString(R.string.credits)) activityController.pause().stop().destroy() // Verify activity was destroyed assertTrue(activity.isDestroyed) } - - @Test - fun testDialogContentMethods() { - activityController.create().start().resume() - - // Use reflection to test private methods - try { - val showCreditsMethod: Method = AboutActivity::class.java.getDeclaredMethod("showCredits") - showCreditsMethod.isAccessible = true - showCreditsMethod.invoke(activity) // Should not throw exception - - val showHistoryMethod: Method = AboutActivity::class.java.getDeclaredMethod("showHistory", View::class.java) - showHistoryMethod.isAccessible = true - showHistoryMethod.invoke(activity, activity.findViewById(R.id.version_history)) // Should not throw exception - } catch (e: Exception) { - fail("Exception when calling dialog methods: ${e.message}") - } - } - - @Test - fun testExternalBrowserWithDifferentURLs() { - activityController.create().start().resume() - - try { - // Get access to the private method - val openExternalBrowserMethod: Method = AboutActivity::class.java.getDeclaredMethod("openExternalBrowser", View::class.java) - openExternalBrowserMethod.isAccessible = true - - // Create test URLs - val testUrls = arrayOf( - "https://hosted.weblate.org/engage/catima/", - "https://github.com/CatimaLoyalty/Android/blob/main/LICENSE", - "https://catima.app/privacy-policy/", - "https://github.com/CatimaLoyalty/Android/issues" - ) - - for (url in testUrls) { - // Create a View with the URL as tag - val testView = View(activity) - testView.tag = url - - // Call the method directly - openExternalBrowserMethod.invoke(activity, testView) - - // Verify the intent - val intent = shadowActivity.nextStartedActivity - assertNotNull("No intent launched for URL: $url", intent) - assertEquals(Intent.ACTION_VIEW, intent.action) - assertEquals(Uri.parse(url), intent.data) - } - } catch (e: Exception) { - fail("Exception during reflection: ${e.message}") - } - } - - @Test - fun testButtonVisibilityBasedOnBuildConfig() { - activityController.create().start().resume() - - // Get the current values from BuildConfig - val showRateOnGooglePlay = BuildConfig.showRateOnGooglePlay - val showDonate = BuildConfig.showDonate - - // Test that the visibility matches the BuildConfig values - assertEquals(showRateOnGooglePlay, activity.findViewById(R.id.rate).isVisible) - assertEquals(showDonate, activity.findViewById(R.id.donate).isVisible) - } - - @Test - fun testAboutScreenTextContent() { - activityController.create().start().resume() - - // Verify that text fields contain the expected content - val creditsSub = activity.findViewById(R.id.credits_sub) - assertNotNull(creditsSub.text) - assertFalse(creditsSub.text.toString().isEmpty()) - - val versionHistorySub = activity.findViewById(R.id.version_history_sub) - assertNotNull(versionHistorySub.text) - assertFalse(versionHistorySub.text.toString().isEmpty()) - } -} +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ce82c4000..065c16e11b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,8 @@ pluginManagement { repositories { - gradlePluginPortal() google() mavenCentral() + gradlePluginPortal() } } dependencyResolutionManagement {