diff options
Diffstat (limited to 'mobile/android/android-components/components/lib')
280 files changed, 22471 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/lib/auth/build.gradle b/mobile/android/android-components/components/lib/auth/build.gradle new file mode 100644 index 0000000000..26f11505ee --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/build.gradle @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.auth' +} + +dependencies { + implementation project(':support-base') + implementation ComponentsDependencies.androidx_biometric + + testImplementation project(':support-test') + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/auth/proguard-rules.pro b/mobile/android/android-components/components/lib/auth/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile
\ No newline at end of file diff --git a/mobile/android/android-components/components/lib/auth/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/auth/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..41078a7325 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/AuthenticationDelegate.kt b/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/AuthenticationDelegate.kt new file mode 100644 index 0000000000..c1cb5265c3 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/AuthenticationDelegate.kt @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.auth + +/** + * Callbacks for BiometricPrompt Authentication + */ +interface AuthenticationDelegate { + + /** + * Called when a biometric (e.g. fingerprint, face, etc.) + * is presented but not recognized as belonging to the user. + */ + fun onAuthFailure() + + /** + * Called when a biometric (e.g. fingerprint, face, etc.) is recognized, + * indicating that the user has successfully authenticated. + */ + fun onAuthSuccess() + + /** + * Called when an unrecoverable error has been encountered and authentication has stopped. + * @param errorText A human-readable error string that can be shown on an UI + */ + fun onAuthError(errorText: String) +} diff --git a/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricPromptAuth.kt b/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricPromptAuth.kt new file mode 100644 index 0000000000..a815bebe39 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricPromptAuth.kt @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.auth + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.log.logger.Logger + +/** + * A [LifecycleAwareFeature] for the Android Biometric API to prompt for user authentication. + * The prompt also requests support for the device PIN as a fallback authentication mechanism. + * + * @param context Android context. + * @param fragment The fragment on which this feature will live. + * @param authenticationDelegate Callbacks for BiometricPrompt. + */ +class BiometricPromptAuth( + private val context: Context, + private val fragment: Fragment, + private val authenticationDelegate: AuthenticationDelegate, +) : LifecycleAwareFeature { + private val logger = Logger(javaClass.simpleName) + + @VisibleForTesting + internal var biometricPrompt: BiometricPrompt? = null + + override fun start() { + val executor = ContextCompat.getMainExecutor(context) + biometricPrompt = BiometricPrompt(fragment, executor, PromptCallback()) + } + + override fun stop() { + biometricPrompt = null + } + + /** + * Requests the user for biometric authentication. + * + * @param title Adds a title for the authentication prompt. + * @param subtitle Adds a subtitle for the authentication prompt. + */ + fun requestAuthentication( + title: String, + subtitle: String = "", + ) { + val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + .setTitle(title) + .setSubtitle(subtitle) + .build() + biometricPrompt?.authenticate(promptInfo) + } + + internal inner class PromptCallback : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + logger.error("onAuthenticationError: errorMessage $errString errorCode=$errorCode") + authenticationDelegate.onAuthError(errString.toString()) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + logger.debug("onAuthenticationSucceeded") + authenticationDelegate.onAuthSuccess() + } + + override fun onAuthenticationFailed() { + logger.error("onAuthenticationFailed") + authenticationDelegate.onAuthFailure() + } + } +} diff --git a/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricUtils.kt b/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricUtils.kt new file mode 100644 index 0000000000..3f4ca88fc1 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/src/main/java/mozilla/components/lib/auth/BiometricUtils.kt @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.auth + +import android.content.Context +import android.os.Build +import androidx.biometric.BiometricManager + +/** + * Utility class for BiometricPromptAuth + */ + +fun Context.canUseBiometricFeature(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val manager = BiometricManager.from(this) + return BiometricUtils.canUseFeature(manager) + } else { + false + } +} + +internal object BiometricUtils { + + /** + * Checks if the appropriate SDK version and hardware capabilities are met to use the feature. + */ + internal fun canUseFeature(manager: BiometricManager): Boolean { + return isHardwareAvailable(manager) && isEnrolled(manager) + } + + /** + * Checks if the hardware requirements are met for using the [BiometricManager]. + */ + internal fun isHardwareAvailable(biometricManager: BiometricManager): Boolean { + val status = + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + return status != BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE && + status != BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE + } + + /** + * Checks if the user can use the [BiometricManager] and is therefore enrolled. + */ + internal fun isEnrolled(biometricManager: BiometricManager): Boolean { + val status = + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + return status == BiometricManager.BIOMETRIC_SUCCESS + } +} diff --git a/mobile/android/android-components/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricPromptAuthTest.kt b/mobile/android/android-components/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricPromptAuthTest.kt new file mode 100644 index 0000000000..1c74d24da9 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricPromptAuthTest.kt @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.auth + +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.createAddedTestFragment +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BiometricPromptAuthTest { + + private lateinit var biometricPromptAuth: BiometricPromptAuth + private lateinit var fragment: Fragment + + @Before + fun setup() { + fragment = createAddedTestFragment { Fragment() } + biometricPromptAuth = BiometricPromptAuth( + testContext, + fragment, + object : AuthenticationDelegate { + override fun onAuthFailure() { + } + + override fun onAuthSuccess() { + } + + override fun onAuthError(errorText: String) { + } + }, + ) + } + + @Test + fun `prompt is created and destroyed on start and stop`() { + assertNull(biometricPromptAuth.biometricPrompt) + + biometricPromptAuth.start() + + assertNotNull(biometricPromptAuth.biometricPrompt) + + biometricPromptAuth.stop() + + assertNull(biometricPromptAuth.biometricPrompt) + } + + @Test + fun `requestAuthentication invokes biometric prompt`() { + val prompt: BiometricPrompt = mock() + + biometricPromptAuth.biometricPrompt = prompt + + biometricPromptAuth.requestAuthentication("title", "subtitle") + + verify(prompt).authenticate(any()) + } + + @Test + fun `promptCallback fires feature callbacks`() { + val authenticationDelegate: AuthenticationDelegate = mock() + val feature = BiometricPromptAuth(testContext, fragment, authenticationDelegate) + val callback = feature.PromptCallback() + val prompt = BiometricPrompt(fragment, callback) + + feature.biometricPrompt = prompt + + callback.onAuthenticationError(BiometricPrompt.ERROR_CANCELED, "") + + verify(authenticationDelegate).onAuthError("") + + callback.onAuthenticationFailed() + + verify(authenticationDelegate).onAuthFailure() + + callback.onAuthenticationSucceeded(mock()) + + verify(authenticationDelegate).onAuthSuccess() + } +} diff --git a/mobile/android/android-components/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricUtilsTest.kt b/mobile/android/android-components/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricUtilsTest.kt new file mode 100644 index 0000000000..c8c9d53b70 --- /dev/null +++ b/mobile/android/android-components/components/lib/auth/src/test/java/mozilla/components/lib/auth/BiometricUtilsTest.kt @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.auth + +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class BiometricUtilsTest { + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `canUseFeature checks for SDK compatible`() { + assertFalse(testContext.canUseBiometricFeature()) + } + + @Test + fun `isHardwareAvailable is true based on AuthenticationStatus`() { + val manager: BiometricManager = mock { + whenever(canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) + .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) + .thenReturn(BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE) + .thenReturn(BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) + } + + assertTrue(BiometricUtils.isHardwareAvailable(manager)) + assertFalse(BiometricUtils.isHardwareAvailable(manager)) + assertFalse(BiometricUtils.isHardwareAvailable(manager)) + } + + @Test + fun `isEnrolled is true based on AuthenticationStatus`() { + val manager: BiometricManager = mock { + whenever(canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) + .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) + } + assertTrue(BiometricUtils.isEnrolled(manager)) + } +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/build.gradle b/mobile/android/android-components/components/lib/crash-sentry/build.gradle new file mode 100644 index 0000000000..caba4d5650 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/build.gradle @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.crash.sentry' +} + +dependencies { + implementation project(':support-base') + implementation project(':support-ktx') + implementation project(':support-utils') + implementation project(':lib-crash') + + implementation ComponentsDependencies.thirdparty_sentry + testImplementation ComponentsDependencies.thirdparty_sentry + + testImplementation project(':support-test') + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/crash-sentry/proguard-rules.pro b/mobile/android/android-components/components/lib/crash-sentry/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/crash-sentry/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7b04326db6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + <application> + <meta-data + android:name="io.sentry.auto-init" + android:value="false" /> + <provider + android:name="io.sentry.android.core.SentryInitProvider" + android:authorities="${applicationId}.SentryInitProvider" + tools:node="remove" /> + </application> +</manifest> diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/SentryService.kt b/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/SentryService.kt new file mode 100644 index 0000000000..fbe4c5e874 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/SentryService.kt @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.sentry + +import android.content.Context +import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting +import io.sentry.Breadcrumb +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.android.core.SentryAndroid +import io.sentry.protocol.SentryId +import mozilla.components.Build +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.sentry.eventprocessors.AddMechanismEventProcessor +import mozilla.components.lib.crash.sentry.eventprocessors.RustCrashEventProcessor +import mozilla.components.lib.crash.service.CrashReporterService +import java.util.Locale +import mozilla.components.concept.base.crash.Breadcrumb as MozillaBreadcrumb + +/** + * A [CrashReporterService] implementation that uploads crash reports using + * the Sentry SDK version 5.6.1 and above. + * + * This implementation will add default tags to every sent crash report + * (like which Android Components version is being used) prefixed with "ac". + * + * @param applicationContext The application [Context]. + * @param dsn Data Source Name of the Sentry server. + * @param tags A list of additional tags that will be sent together with crash reports. + * @param environment An optional, environment name string or null to set none + * @param sendEventForNativeCrashes Allows configuring if native crashes should be submitted. Disabled by default. + * @param sentryProjectUrl Base URL of the Sentry web interface pointing to the app/project. + * @param sendCaughtExceptions Allows configuring if caught exceptions should be submitted. Enabled by default. + * @param autoInitializeSentry Initializes the Sentry SDK immediately on service creation. + */ +class SentryService( + private val applicationContext: Context, + private val dsn: String, + private val tags: Map<String, String> = emptyMap(), + private val environment: String? = null, + private val sendEventForNativeCrashes: Boolean = false, + private val sentryProjectUrl: String? = null, + private val sendCaughtExceptions: Boolean = true, +) : CrashReporterService { + + override val id: String = "new-sentry-instance" + override val name: String = "New Sentry Instance" + + @VisibleForTesting + @GuardedBy("this") + internal var isInitialized: Boolean = false + + override fun createCrashReportUrl(identifier: String): String? { + return sentryProjectUrl?.let { + val id = identifier.replace("-", "") + return "$it&query=$id" + } + } + + override fun report(crash: Crash.UncaughtExceptionCrash): String { + prepareReport(crash.breadcrumbs, SentryLevel.FATAL) + return reportToSentry(crash.throwable) + } + + override fun report(crash: Crash.NativeCodeCrash): String? { + return if (sendEventForNativeCrashes) { + val level = when (crash.isFatal) { + true -> SentryLevel.FATAL + else -> SentryLevel.ERROR + } + + prepareReport(crash.breadcrumbs, level) + + return reportToSentry(crash) + } else { + null + } + } + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<MozillaBreadcrumb>): String? { + if (!sendCaughtExceptions) { + return null + } + prepareReport(breadcrumbs, SentryLevel.INFO) + return reportToSentry(throwable) + } + + @VisibleForTesting + internal fun reportToSentry(throwable: Throwable): String { + return Sentry.captureException(throwable).alsoClearBreadcrumbs() + } + + @VisibleForTesting + internal fun reportToSentry(crash: Crash.NativeCodeCrash): String { + return Sentry.captureMessage(createMessage(crash)).alsoClearBreadcrumbs() + } + + private fun addDefaultTags() { + Sentry.setTag("ac.version", Build.version) + Sentry.setTag("ac.git", Build.gitHash) + Sentry.setTag("ac.as.build_version", Build.applicationServicesVersion) + Sentry.setTag("ac.glean.build_version", Build.gleanSdkVersion) + Sentry.setTag("user.locale", Locale.getDefault().toString()) + tags.forEach { entry -> + Sentry.setTag(entry.key, entry.value) + } + } + + /** + * Initializes Sentry if needed. + * + * N.B: We've temporarily made this public so that Fenix can initialize Sentry on startup. + * As a result of https://bugzilla.mozilla.org/show_bug.cgi?id=1853059 we will have a better way + * to control how / when Sentry gets initialized and we will make this internal again. + */ + @Synchronized + fun initIfNeeded() { + if (isInitialized) { + return + } + initSentry() + addDefaultTags() + isInitialized = true + } + + @VisibleForTesting + internal fun initSentry() { + SentryAndroid.init(applicationContext) { options -> + // Disable uncaught non-native exceptions from being reported. + // We already have our own uncaught exception handler [ExceptionHandler], + // so we don't need Sentry's default one. + options.setEnableUncaughtExceptionHandler(false) + // Disable uncaught native exceptions from being reported. + // Sentry don't have a way to disable uncaught native exceptions from being reported. + // As a fallback we had to disable all native integrations. + // More info can be found https://github.com/getsentry/sentry-java/issues/1993 + options.isEnableNdk = false + options.dsn = dsn + options.environment = environment + options.addEventProcessor(RustCrashEventProcessor()) + options.addEventProcessor(AddMechanismEventProcessor()) + } + } + + @VisibleForTesting + internal fun prepareReport( + breadcrumbs: ArrayList<MozillaBreadcrumb>, + level: SentryLevel? = null, + ) { + initIfNeeded() + + breadcrumbs.forEach { + Sentry.addBreadcrumb(it.toSentryBreadcrumb()) + } + + level?.apply { + Sentry.setLevel(level) + } + } + + private fun SentryId.alsoClearBreadcrumbs(): String { + Sentry.clearBreadcrumbs() + return this.toString() + } + + @VisibleForTesting + internal fun createMessage(crash: Crash.NativeCodeCrash): String { + val fatal = crash.isFatal.toString() + val processType = crash.processType + val minidumpSuccess = crash.minidumpSuccess + + return "NativeCodeCrash(fatal=$fatal, processType=$processType, minidumpSuccess=$minidumpSuccess)" + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun MozillaBreadcrumb.toSentryBreadcrumb(): Breadcrumb { + val sentryLevel = this.level.toSentryBreadcrumbLevel() + val breadcrumb = Breadcrumb(this.date).apply { + message = this@toSentryBreadcrumb.message + category = this@toSentryBreadcrumb.category + level = sentryLevel + type = this@toSentryBreadcrumb.type.value + } + this.data.forEach { + breadcrumb.setData(it.key, it.value) + } + return breadcrumb +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun MozillaBreadcrumb.Level.toSentryBreadcrumbLevel() = when (this) { + MozillaBreadcrumb.Level.CRITICAL -> SentryLevel.FATAL + MozillaBreadcrumb.Level.ERROR -> SentryLevel.ERROR + MozillaBreadcrumb.Level.WARNING -> SentryLevel.WARNING + MozillaBreadcrumb.Level.INFO -> SentryLevel.INFO + MozillaBreadcrumb.Level.DEBUG -> SentryLevel.DEBUG +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/eventprocessors/AddMechanismEventProcessor.kt b/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/eventprocessors/AddMechanismEventProcessor.kt new file mode 100644 index 0000000000..92adbd2906 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/eventprocessors/AddMechanismEventProcessor.kt @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.sentry.eventprocessors + +import androidx.annotation.VisibleForTesting +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.protocol.Mechanism + +/** + * A [EventProcessor] implementation that adds a [Machanism] + * to [SentryLevel.FATAL] events. + */ +class AddMechanismEventProcessor : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent { + if (event.level == SentryLevel.FATAL) { + // Sentry now uses the `Mechanism` to determine whether or not an exception is + // handled. Any exception sent with `Sentry.captureException` is assumed to be handled + // by Sentry. We can attach a `UncaughtExceptionHandler` mechanism to the `SentryException` + // to correctly signal to Sentry that this is an uncaught exception. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1835107 + event.exceptions?.firstOrNull()?.let { sentryException -> + sentryException.mechanism = Mechanism().apply { + type = UNCAUGHT_EXCEPTION_TYPE + isHandled = false + } + } + } + + return event + } + + companion object { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal const val UNCAUGHT_EXCEPTION_TYPE = "UncaughtExceptionHandler" + } +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/eventprocessors/RustCrashEventProcessor.kt b/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/eventprocessors/RustCrashEventProcessor.kt new file mode 100644 index 0000000000..a6069699bc --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/main/java/mozilla/components/lib/crash/sentry/eventprocessors/RustCrashEventProcessor.kt @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.sentry.eventprocessors + +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.SentryEvent +import mozilla.components.concept.base.crash.RustCrashReport as RustCrashReport + +/** + * A [EventProcessor] implementation that cleans up exceptions for + * crashes coming from our Rust libraries. + */ +class RustCrashEventProcessor : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent { + val throwable = event.throwable + + if (throwable is RustCrashReport) { + event.fingerprints = listOf(throwable.typeName) + // Sentry supports multiple exceptions in an event, modify + // the top-level one controls how the event is displayed + // + // It's technically possible for the event to have a null + // or empty exception list, but that shouldn't happen in + // practice. + event.exceptions?.firstOrNull()?.let { sentryException -> + sentryException.type = throwable.typeName + sentryException.value = throwable.message + } + } + + return event + } +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/SentryServiceTest.kt b/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/SentryServiceTest.kt new file mode 100644 index 0000000000..e6a2aa25b6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/SentryServiceTest.kt @@ -0,0 +1,276 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.sentry + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Sentry +import io.sentry.SentryLevel +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.Crash +import mozilla.components.support.test.robolectric.testContext +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.util.Date +import mozilla.components.concept.base.crash.Breadcrumb as MozillaBreadcrumb + +@RunWith(AndroidJUnit4::class) +class SentryServiceTest { + class TestException : Exception() + + @Before + fun setup() { + Sentry.close() + } + + @Test + fun `WHEN calling initIfNeeded THEN initialize sentry once`() { + val service = spy( + SentryService( + testContext, + "https://not:real6@sentry.prod.example.net/405", + sendCaughtExceptions = false, + ), + ) + + assertFalse(service.isInitialized) + + service.initIfNeeded() + + assertTrue(service.isInitialized) + + service.initIfNeeded() + + verify(service, times(1)).initSentry() + } + + @Test + fun `WHEN report a uncaught exception THEN forward a fatal exception to the Sentry sdk`() { + val service = spy( + SentryService( + testContext, + "https://not:real6@sentry.prod.example.net/405", + ), + ) + + val exception = RuntimeException("Hello World") + val breadcrumbs = arrayListOf<Breadcrumb>() + + service.report(Crash.UncaughtExceptionCrash(0, exception, breadcrumbs)) + + verify(service).prepareReport(breadcrumbs, SentryLevel.FATAL) + verify(service).reportToSentry(exception) + } + + @Test + fun `GIVEN a main process native crash WHEN reporting THEN forward to a fatal crash the Sentry sdk`() { + val service = spy( + SentryService( + applicationContext = testContext, + dsn = "https://not:real6@sentry.prod.example.net/405", + sendEventForNativeCrashes = true, + ), + ) + + val breadcrumbs = arrayListOf<Breadcrumb>() + val nativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = breadcrumbs, + remoteType = null, + ) + + service.report(nativeCrash) + + verify(service).prepareReport(breadcrumbs, SentryLevel.FATAL) + verify(service).reportToSentry(nativeCrash) + } + + @Test + fun `GIVEN a foreground child process native crash WHEN reporting THEN forward an error to the Sentry sdk`() { + val service = spy( + SentryService( + applicationContext = testContext, + dsn = "https://not:real6@sentry.prod.example.net/405", + sendEventForNativeCrashes = true, + ), + ) + + val breadcrumbs = arrayListOf<Breadcrumb>() + val nativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = breadcrumbs, + remoteType = null, + ) + + service.report(nativeCrash) + + verify(service).prepareReport(breadcrumbs, SentryLevel.ERROR) + verify(service).reportToSentry(nativeCrash) + } + + @Test + fun `GIVEN a background child process native crash WHEN reporting THEN forward an error to the Sentry sdk`() { + val service = spy( + SentryService( + applicationContext = testContext, + dsn = "https://not:real6@sentry.prod.example.net/405", + sendEventForNativeCrashes = true, + ), + ) + + val breadcrumbs = arrayListOf<Breadcrumb>() + val nativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = breadcrumbs, + remoteType = null, + ) + + service.report(nativeCrash) + + verify(service).prepareReport(breadcrumbs, SentryLevel.ERROR) + verify(service).reportToSentry(nativeCrash) + } + + @Test + fun `GIVEN sendEventForNativeCrashes is false WHEN reporting a native crash THEN DO NOT forward to the Sentry sdk`() { + val service = spy( + SentryService( + applicationContext = testContext, + dsn = "https://not:real6@sentry.prod.example.net/405", + sendEventForNativeCrashes = false, + ), + ) + + val breadcrumbs = arrayListOf<Breadcrumb>() + val nativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = breadcrumbs, + remoteType = null, + ) + + val result = service.report(nativeCrash) + + verify(service, times(0)).prepareReport(breadcrumbs, SentryLevel.ERROR) + verify(service, times(0)).reportToSentry(nativeCrash) + assertNull(result) + } + + @Test + fun `WHEN createMessage THEN create a message version of the Native crash`() { + val service = SentryService( + applicationContext = testContext, + dsn = "https://not:real6@sentry.prod.example.net/405", + sendEventForNativeCrashes = false, + ) + + val breadcrumbs = arrayListOf<Breadcrumb>() + val nativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = breadcrumbs, + remoteType = null, + ) + + val result = service.createMessage(nativeCrash) + val expected = + "NativeCodeCrash(fatal=${nativeCrash.isFatal}, processType=${nativeCrash.processType}, minidumpSuccess=${nativeCrash.minidumpSuccess})" + + assertEquals(expected, result) + } + + @Test + fun `GIVEN MozillaBreadcrumb WHEN calling toSentryBreadcrumb THEN parse it to a SentryBreadcrumb`() { + val mozillaBreadcrumb = MozillaBreadcrumb( + message = "message", + data = mapOf("key" to "value"), + category = "category", + level = MozillaBreadcrumb.Level.INFO, + type = MozillaBreadcrumb.Type.DEFAULT, + date = Date(1640995200L), // 2022-01-01 + ) + val sentryBreadcrumb = mozillaBreadcrumb.toSentryBreadcrumb() + + assertEquals(mozillaBreadcrumb.message, sentryBreadcrumb.message) + assertEquals(mozillaBreadcrumb.data["key"], sentryBreadcrumb.getData("key")) + assertEquals(mozillaBreadcrumb.category, sentryBreadcrumb.category) + assertEquals(SentryLevel.INFO, sentryBreadcrumb.level) + assertEquals(MozillaBreadcrumb.Type.DEFAULT.value, sentryBreadcrumb.type) + assertEquals(mozillaBreadcrumb.date, sentryBreadcrumb.timestamp) + } + + @Test + fun `GIVEN MozillaBreadcrumb level WHEN calling toSentryBreadcrumbLevel THEN parse it to a SentryBreadcrumbLevel`() { + assertEquals(MozillaBreadcrumb.Level.CRITICAL.toSentryBreadcrumbLevel(), SentryLevel.FATAL) + assertEquals(MozillaBreadcrumb.Level.ERROR.toSentryBreadcrumbLevel(), SentryLevel.ERROR) + assertEquals(MozillaBreadcrumb.Level.WARNING.toSentryBreadcrumbLevel(), SentryLevel.WARNING) + assertEquals(MozillaBreadcrumb.Level.INFO.toSentryBreadcrumbLevel(), SentryLevel.INFO) + assertEquals(MozillaBreadcrumb.Level.DEBUG.toSentryBreadcrumbLevel(), SentryLevel.DEBUG) + } + + @Test + fun `GIVEN sending caught exceptions disabled WHEN reporting a caught exception THEN do nothing`() { + val service = spy( + SentryService( + testContext, + "https://not:real6@sentry.prod.example.net/405", + sendCaughtExceptions = false, + ), + ) + + val exception = RuntimeException("Hello World") + val breadcrumbs = arrayListOf<Breadcrumb>() + + service.report(exception, breadcrumbs) + verify(service, never()).prepareReport(breadcrumbs, SentryLevel.INFO) + verify(service, never()).prepareReport(breadcrumbs, SentryLevel.FATAL) + verify(service, never()).reportToSentry(exception) + } + + @Test + fun `GIVEN sending caught exceptions enabled WHEN reporting a caught exception THEN forward it to Sentry SDK with level INFO`() { + val service = spy( + // Sending caught exceptions is enabled by default. + SentryService( + testContext, + "https://not:real6@sentry.prod.example.net/405", + ), + ) + + val exception = RuntimeException("Hello World") + val breadcrumbs = arrayListOf<Breadcrumb>() + + service.report(exception, breadcrumbs) + + verify(service).prepareReport(breadcrumbs, SentryLevel.INFO) + verify(service).reportToSentry(exception) + } +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/eventprocessors/AddMechanismEventProcessorTest.kt b/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/eventprocessors/AddMechanismEventProcessorTest.kt new file mode 100644 index 0000000000..5f14b0232a --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/eventprocessors/AddMechanismEventProcessorTest.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.sentry.eventprocessors + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.protocol.SentryException +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AddMechanismEventProcessorTest { + @Test + fun `GIVEN a FATAL SentryEvent WHEN process is called THEN a Mechanism is attached to the exception`() { + val processor = AddMechanismEventProcessor() + val event = SentryEvent().apply { + level = SentryLevel.FATAL + exceptions = listOf(SentryException()) + } + + assertNull(event.exceptions?.first()?.mechanism) + processor.process(event, Hint()) + assertEquals(AddMechanismEventProcessor.UNCAUGHT_EXCEPTION_TYPE, event.exceptions?.first()?.mechanism?.type) + assertTrue(event.exceptions?.first()?.mechanism?.isHandled == false) + } + + @Test + fun `GIVEN a less than FATAL SentryEvent WHEN process is called THEN no Mechanism is attached to the exception`() { + val processor = AddMechanismEventProcessor() + val event = SentryEvent().apply { + level = SentryLevel.INFO + exceptions = listOf(SentryException()) + } + + assertNull(event.exceptions?.first()?.mechanism) + processor.process(event, Hint()) + assertNull(event.exceptions?.first()?.mechanism) + } +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/eventprocessors/RustCrashEventProcessorTest.kt b/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/eventprocessors/RustCrashEventProcessorTest.kt new file mode 100644 index 0000000000..7190f44b25 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/test/java/mozilla/components/lib/crash/sentry/eventprocessors/RustCrashEventProcessorTest.kt @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.sentry.eventprocessors + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.protocol.SentryException +import junit.framework.TestCase.assertEquals +import mozilla.components.concept.base.crash.RustCrashReport +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RustCrashEventProcessorTest { + class TestRustException : Exception(), RustCrashReport { + override val typeName = "test_rust_crash" + override val message = "test_rust_message" + } + + @Test + fun `GIVEN a SentryEvent that contains a RustCrashReport WHEN process is called THEN a fingerprint is added and the exception type and value are cleaned up`() { + val processor = RustCrashEventProcessor() + val event = SentryEvent(TestRustException()).apply { + exceptions = listOf(SentryException()) + } + + processor.process(event, Hint()) + assertEquals("test_rust_crash", event.fingerprints?.first()) + assertEquals("test_rust_crash", event.exceptions?.firstOrNull()?.type) + assertEquals("test_rust_message", event.exceptions?.firstOrNull()?.value) + } +} diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/lib/crash-sentry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/lib/crash-sentry/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/crash-sentry/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/crash-sentry/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/crash/README.md b/mobile/android/android-components/components/lib/crash/README.md new file mode 100644 index 0000000000..43208dd3ce --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/README.md @@ -0,0 +1,239 @@ +# [Android Components](../../../README.md) > Libraries > Crash + +A generic crash reporter component that can report crashes to multiple services. + +Main features: + +* Support for multiple crash reporting services (included is support for [Sentry](https://sentry.io) and [Socorro](https://wiki.mozilla.org/Socorro)). +* Support for crashes caused by uncaught exceptions. +* Support for native code crashes (currently primarily focused on GeckoView crashes). +* Can optionally prompt the user for confirmation before sending a crash report. +* Support for showing in-app confirmation UI for non-fatal crashes. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-crash:{latest-version}" +``` + +### Setting up crash reporting + +In the `onCreate()` method of your Application class create a `CrashReporter` instance and call `install()`: + +```Kotlin +CrashReporter( + services = listOf( + // List the crash reporting services you want to use + ) +).install(this) +``` + +With this minimal setup the crash reporting library will capture "uncaught exception" crashes and "native code" crashes and forward them to the configured crash reporting services. + +⚠️ Note: To avoid conflicting setups do not use any other crash reporting libraries/services independently from this library. + +### Recording crash breadcrumbs to supported services + +Using the `CrashReporter` instance to record crash breadcrumbs. These breadcrumbs will then be sent when a crash occurs to aid in debugging. Breadcrumbs are reported only if the underlying crash reporter service supports it. + +⚠️ Note: Directly using Sentry's breadcrumb will not work as expected on Android 10 or above. Using the `CrashReporter` breadcrumb is preferred. + +```Kotlin +crashReporter.recordCrashBreadcrumb( + CrashBreadcrumb("Settings button clicked", data, "UI", Level.INFO, Type.USER) +) +``` + +### Sending crash reports to Sentry + +⚠️ Note: The crash reporter library is compiled against the Sentry SDK but it doesn't require it as a dependency. The app using the component is responsible for adding the Sentry dependency to its build files in order to use Sentry crash reporting. + +Add a `SentryService` instance to your `CrashReporter` in order to upload crashes to Sentry: + +```Kotlin +CrashReporter( + services = listOf( + SentryService(applicationContext, "your sentry DSN") + ) +).install(applicationContext) +``` + +By default only the `DSN` is needed. But there are additional option configuration parameters: + +```Kotlin +SentryService( + applicationContext, + "your sentry DSN", + + // Optionally add tags that will be sent with every crash report + tags = mapOf( + "build_flavor" to BuildConfig.FLAVOR, + "build_type" to BuildConfig.BUILD_TYPE + ), + + // Send an event to Sentry for every native code crash. Native code crashes + // can't be uploaded to Sentry currently. But sending an event to Sentry + // gives you an idea about how often native code crashes. For sending native + // crash reports add additional services like Socorro. + sendEventForNativeCrashes = true +) +``` + +### Sending crash reports to Mozilla Socorro + +[Socorro](https://wiki.mozilla.org/Socorro) is the name for the [Mozilla Crash Stats](https://crash-stats.mozilla.org/) project. + +⚠️ Note: Socorro filters crashes by "app name". New app names need to be safelisted for the server to accept the crash. [File a bug](https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro) if you would like to get your app added to the safelist. + +Add a `MozillaSocorroService` instance to your `CrashReporter` in order to upload crashes to Socorro: + +```Kotlin +CrashReporter( + services = listOf( + MozillaSocorroService(applicationContext, "your app name") + ) +).install(applicationContext) +``` + +`MozillaSocorroService` will report version information such as App version, Android Component version, Glean version, Application Services version, GeckoView version and Build ID +⚠️ Note: Currently only native code crashes get uploaded to Socorro. Socorro has limited support for "uncaught exception" crashes too, but it is recommended to use a more elaborate solution like Sentry for that. + +### Sending crash reports to Glean + +[Glean](https://docs.telemetry.mozilla.org/concepts/glean/glean.html) is a new way to collect telemetry by Mozilla. +This will record crash counts as a labeled counter with each label corresponding to a specific type of crash (`fatal_native_code_crash`, `nonfatal_native_code_crash`, `caught_exception`, `uncaught_exception`, currently). +The list of collected metrics is available in the [metrics.yaml file](metrics.yaml), with their documentation [living here](https://dictionary.telemetry.mozilla.org/apps/fenix/pings/crash). +Due to the fact that Glean can only be recorded to in the main process and lib-crash runs in a separate process when it runs to handle the crash, +lib-crash persists the data in a file format and then reads and records the data from the main process when the application is next run since the `GleanCrashReporterService` +constructor is loaded from the main process. + +Add a `GleanCrashReporterService` instance to your `CrashReporter` in order to record crashes in Glean: + +```Kotlin +CrashReporter( + services = listOf( + GleanCrashReporterService() + ) +).install(applicationContext) +``` + +⚠️ Note: Applications using the `GleanCrashReporterService` are **required** to undergo [Data Collection Review](https://wiki.mozilla.org/Firefox/Data_Collection) for the crash counts that they will be collecting. + +### Showing a crash reporter prompt + +![](images/crash-dialog.png) + +Optionally the library can show a prompt asking the user for confirmation before sending a crash report. + +The behavior can be controlled using the `shouldPrompt` parameter: + +```Kotlin +CrashReporter( + // Always prompt + shouldPrompt = CrashReporter.Prompt.ALWAYS, + + // Or: Only prompt for native crashes + shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH, + + // Or: Never show the prompt + shouldPrompt = CrashReporter.Prompt.NEVER, + + // .. +).install(applicationContext) +``` + +#### Customizing the prompt + +The crash reporter prompt can be customized by providing a `PromptConfiguration` object: + +```Kotlin +CrashReporter( + promptConfiguration = CrashReporter.PromptConfiguration( + appName = "My App", + organizationName = "My Organization", + + // An additional message that will be shown in the prompt + message = "We are very sorry!" + + // Use a custom theme for the prompt (Extend Theme.Mozac.CrashReporter) + theme = android.R.style.Theme_Holo_Dialog + ), + + // .. +).install(applicationContext) +``` + +#### Handling non-fatal crashes + +A native code crash can be non-fatal. In this situation a child process crashed but the main process (in which the application runs) is not affected. In this situation a crash can be handled more gracefully and instead of using the crash reporter prompt of the component an app may want to show an in-app UI for asking the user for confirmation. + +![](images/crash-in-app.png) + +Provide a `PendingIntent` that will be invoked when a non-fatal crash occurs: + +```Kotlin +// Launch this activity when a crash occurs. +val pendingIntent = PendingIntent.getActivity( + context, + 0, + Intent(this, MyActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + PendingIntentUtils.defaultFlags +) + +CrashReporter( + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf( + // ... + ), + nonFatalCrashIntent = pendingIntent +).install(this) +``` + +In your component that receives the Intent (e.g. `Activity`) you can use `Crash.fromIntent()` to receive the `Crash` object. Once the user has approved sending a report call `submitReport()` on your `CrashReporter` instance. + +```Kotlin +// In your crash handling component (e.g. Activity) +if (Crash.isCrashIntent(intent) { + val crash = Crash.fromIntent(intent) + + ... +} + +// Once the user has confirmed sending a crash report: +crashReporter.submitReport(crash) +``` + +⚠️ Note: `submitReport()` may block and perform I/O on the calling thread. + +### Sending GeckoView crash reports + +⚠️ Note: For sending GeckoView crash reports GeckoView **64.0** or higher is required! + +Register `CrashHandlerService` as crash handler for GeckoView: + +```Kotlin +val settings = GeckoRuntimeSettings.Builder() + .crashHandler(CrashHandlerService::class.java) + .build() + +// Crashes of this runtime will be forwarded to the crash reporter component +val runtime = GeckoRuntime.create(applicationContext, settings) + +// If you are using the browser-engine-gecko component then pass the runtime +// to your code initializing the engine: +val engine = GeckoEngine(applicationContext, defaultSettings, runtime) +``` + +ℹ️ You can force a child process crash (non fatal!) using a multi-process (E10S) GeckoView by loading the test URL `about:crashcontent`. Using a non-multi-process GeckoView you can use `about:crashparent` to force a fatal crash. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/crash/build.gradle b/mobile/android/android-components/components/lib/crash/build.gradle new file mode 100644 index 0000000000..afedcf3044 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/build.gradle @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +buildscript { + repositories { + gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORIES.each { repository -> + maven { + url repository + if (gradle.mozconfig.substs.ALLOW_INSECURE_GRADLE_REPOSITORIES) { + allowInsecureProtocol = true + } + } + } + } + + dependencies { + classpath "org.mozilla.telemetry:glean-gradle-plugin:${Versions.mozilla_glean}" + classpath ComponentsDependencies.plugin_serialization + } +} + +plugins { + id "com.jetbrains.python.envs" version "$python_envs_plugin" +} + +apply plugin: 'com.android.library' +apply plugin: 'com.google.devtools.ksp' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.incremental": "true"] + } + } + } + + ksp { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + buildConfig true + } + + namespace 'mozilla.components.lib.crash' +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.kotlin_json + + implementation ComponentsDependencies.androidx_appcompat + implementation ComponentsDependencies.androidx_constraintlayout + implementation ComponentsDependencies.androidx_recyclerview + + implementation project(':support-base') + implementation project(':support-ktx') + implementation project(':support-utils') + + implementation ComponentsDependencies.androidx_room_runtime + ksp ComponentsDependencies.androidx_room_compiler + + // We only compile against GeckoView and Glean. It's up to the app to add those dependencies if it wants to + // send crash reports to Socorro (GV). + compileOnly project(":service-glean") + testImplementation project(":service-glean") + testImplementation ComponentsDependencies.androidx_work_testing + + testImplementation project(':support-test') + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + testImplementation ComponentsDependencies.testing_mockwebserver + testImplementation ComponentsDependencies.mozilla_glean_forUnitTests +} + +apply plugin: "org.mozilla.telemetry.glean-gradle-plugin" +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/crash/images/crash-dialog.png b/mobile/android/android-components/components/lib/crash/images/crash-dialog.png Binary files differnew file mode 100644 index 0000000000..6fc96cc167 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/images/crash-dialog.png diff --git a/mobile/android/android-components/components/lib/crash/images/crash-in-app.png b/mobile/android/android-components/components/lib/crash/images/crash-in-app.png Binary files differnew file mode 100644 index 0000000000..25392af00c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/images/crash-in-app.png diff --git a/mobile/android/android-components/components/lib/crash/metrics.yaml b/mobile/android/android-components/components/lib/crash/metrics.yaml new file mode 100644 index 0000000000..bf30991944 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/metrics.yaml @@ -0,0 +1,154 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# This file defines the metrics that are recorded by glean telemetry. They are +# automatically converted to Kotlin code at build time using the `glean_parser` +# PyPI package. +--- + +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + +crash_metrics: + crash_count: + type: labeled_counter + description: > + Counts the number of crashes that occur in the application. + This measures only the counts of each crash in association + with the labeled type of the crash. + The labels correspond to the types of crashes handled by lib-crash. + + Deprecated: `native_code_crash`, `fatal_native_code_crash` and + `nonfatal_native_code_crash` replaced by `main_proc_native_code_crash`, + `fg_proc_native_code_crash` and `bg_proc_native_code_crash`. + labels: + - uncaught_exception + - caught_exception + - main_proc_native_code_crash + - fg_proc_native_code_crash + - bg_proc_native_code_crash + - fatal_native_code_crash + - nonfatal_native_code_crash + bugs: + - https://bugzilla.mozilla.org/1553935 + - https://github.com/mozilla-mobile/android-components/issues/5175 + - https://github.com/mozilla-mobile/android-components/issues/11876 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1553935#c3 + - https://github.com/mozilla-mobile/android-components/pull/5700#pullrequestreview-347721248 + - https://github.com/mozilla-mobile/android-components/pull/11908#issuecomment-1075243414 + data_sensitivity: + - technical + notification_emails: + - android-probes@mozilla.com + - jnicol@mozilla.com + expires: never + +crash: + uptime: + type: timespan + description: > + The application uptime. This is equivalent to the legacy crash ping's + `UptimeTS` field. + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569#c12 + data_sensitivity: + - technical + expires: never + send_in_pings: + - crash + + process_type: + type: string + # yamllint disable + description: > + The type of process that experienced a crash. See the full list of + options + [here](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/data/crash-ping.html#process-types). + # yamllint enable + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569#c12 + data_sensitivity: + - technical + expires: never + send_in_pings: + - crash + + remote_type: + type: string + description: > + Type of the child process, can be set to "web", "file" or "extension" but could also be unavailable. + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851518 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851518#c6 + data_sensitivity: + - technical + expires: never + send_in_pings: + - crash + + time: + type: datetime + time_unit: minute + description: > + The time at which the crash occurred. + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569#c12 + data_sensitivity: + - technical + expires: never + send_in_pings: + - crash + + startup: + type: boolean + description: > + If true, the crash occurred during process startup. + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569#c12 + data_sensitivity: + - technical + expires: never + send_in_pings: + - crash + + cause: + type: string + description: > + The cause of the crash. May be one of `os_fault` or `java_exception`. + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1839697 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1839697#c5 + data_sensitivity: + - technical + expires: never + send_in_pings: + - crash diff --git a/mobile/android/android-components/components/lib/crash/pings.yaml b/mobile/android/android-components/components/lib/crash/pings.yaml new file mode 100644 index 0000000000..620e185872 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/pings.yaml @@ -0,0 +1,28 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +--- +$schema: moz://mozilla.org/schemas/glean/pings/2-0-0 + +crash: + description: > + A ping to report crash information. This information is sent as soon as + possible after a crash occurs (whether the crash is a background/content + process or the main process). It is expected to be used for crash report + analysis and to reduce blind spots in crash reporting. + include_client_id: true + send_if_empty: false + notification_emails: + - crash-reporting-wg@mozilla.org + - stability@mozilla.org + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1790569#c12 + reasons: + crash: > + A process crashed and a ping was immediately sent. + event_found: > + A process crashed and produced a crash event, which was later found and + sent in a ping. diff --git a/mobile/android/android-components/components/lib/crash/proguard-rules.pro b/mobile/android/android-components/components/lib/crash/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json b/mobile/android/android-components/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json new file mode 100644 index 0000000000..7ecfe0bbd3 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json @@ -0,0 +1,84 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "212dfa0b59d6a78d81e65cead34d40e0", + "entities": [ + { + "tableName": "crashes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `stacktrace` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stacktrace", + "columnName": "stacktrace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uuid" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `crash_uuid` TEXT NOT NULL, `service_id` TEXT NOT NULL, `report_id` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashUuid", + "columnName": "crash_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportId", + "columnName": "report_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '212dfa0b59d6a78d81e65cead34d40e0')" + ] + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/crash/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3e5c8f7da1 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + + <application android:supportsRtl="true"> + <activity android:name=".prompt.CrashReporterActivity" + android:process=":mozilla.components.lib.crash.CrashReporter" + android:exported="false" + android:excludeFromRecents="true" + android:theme="@style/Theme.Mozac.CrashReporter" /> + + <service android:name=".handler.CrashHandlerService" + android:process=":mozilla.components.lib.crash.CrashHandler" + android:exported="false" + android:foregroundServiceType="specialUse"> + <property + android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" + android:value="This foreground service allows users to report crashes" /> + </service> + + <!-- Separate process to avoid starting the application when starting this service --> + <service android:name=".service.SendCrashReportService" + android:process=":crashReportingProcess" + android:exported="false" + android:foregroundServiceType="specialUse"> + <property + android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" + android:value="This foreground service allows users to report crashes" /> + </service> + + <!-- Separate process to avoid starting the application when starting this service --> + <service android:name=".service.SendCrashTelemetryService" + android:process=":crashReportingProcess" + android:exported="false" + android:foregroundServiceType="specialUse"> + <property + android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" + android:value="This foreground service allows users to report crashes" /> + </service> + </application> + +</manifest> diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/Crash.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/Crash.kt new file mode 100644 index 0000000000..db064e7a6c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/Crash.kt @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import android.content.Intent +import android.os.Bundle +import androidx.annotation.StringDef +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.support.utils.ext.getParcelableArrayListCompat +import mozilla.components.support.utils.ext.getSerializableCompat +import java.io.Serializable +import java.util.UUID + +// Intent extra used to store crash data under when passing crashes in Intent objects +private const val INTENT_CRASH = "mozilla.components.lib.crash.CRASH" + +// Uncaught exception crash intent extras +private const val INTENT_EXCEPTION = "exception" + +// Breadcrumbs intent extras +private const val INTENT_BREADCRUMBS = "breadcrumbs" + +// Crash timestamp intent extras +private const val INTENT_CRASH_TIMESTAMP = "crashTimestamp" + +// Native code crash intent extras (Mirroring GeckoView values) +private const val INTENT_UUID = "uuid" +private const val INTENT_MINIDUMP_PATH = "minidumpPath" +private const val INTENT_EXTRAS_PATH = "extrasPath" +private const val INTENT_MINIDUMP_SUCCESS = "minidumpSuccess" +private const val INTENT_PROCESS_TYPE = "processType" +private const val INTENT_REMOTE_TYPE = "remoteType" + +/** + * Crash types that are handled by this library. + */ +sealed class Crash { + /** + * Unique ID identifying this crash. + */ + abstract val uuid: String + + /** + * A crash caused by an uncaught exception. + * + * @property timestamp Time of when the crash happened. + * @property throwable The [Throwable] that caused the crash. + * @property breadcrumbs List of breadcrumbs to send with the crash report. + */ + data class UncaughtExceptionCrash( + val timestamp: Long, + val throwable: Throwable, + val breadcrumbs: ArrayList<Breadcrumb>, + override val uuid: String = UUID.randomUUID().toString(), + ) : Crash() { + override fun toBundle() = Bundle().apply { + putString(INTENT_UUID, uuid) + putSerializable(INTENT_EXCEPTION, throwable as Serializable) + putLong(INTENT_CRASH_TIMESTAMP, timestamp) + putParcelableArrayList(INTENT_BREADCRUMBS, breadcrumbs) + } + + companion object { + internal fun fromBundle(bundle: Bundle) = UncaughtExceptionCrash( + uuid = bundle.getString(INTENT_UUID) as String, + throwable = bundle.getSerializableCompat(INTENT_EXCEPTION, Throwable::class.java) as Throwable, + breadcrumbs = bundle.getParcelableArrayListCompat(INTENT_BREADCRUMBS, Breadcrumb::class.java) + ?: arrayListOf(), + timestamp = bundle.getLong(INTENT_CRASH_TIMESTAMP, System.currentTimeMillis()), + ) + } + } + + /** + * A crash that happened in native code. + * + * @property timestamp Time of when the crash happened. + * @property minidumpPath Path to a Breakpad minidump file containing information about the crash. + * @property minidumpSuccess Indicating whether or not the crash dump was successfully retrieved. If this is false, + * the dump file may be corrupted or incomplete. + * @property extrasPath Path to a file containing extra metadata about the crash. The file contains key-value pairs + * in the form `Key=Value`. Be aware, it may contain sensitive data such as the URI that was + * loaded at the time of the crash. + * @property processType The type of process the crash occurred in. Affects whether or not the crash is fatal + * or whether the application can recover from it. + * @property breadcrumbs List of breadcrumbs to send with the crash report. + * @property remoteType The type of child process (when available). + */ + data class NativeCodeCrash( + val timestamp: Long, + val minidumpPath: String?, + val minidumpSuccess: Boolean, + val extrasPath: String?, + @ProcessType val processType: String?, + val breadcrumbs: ArrayList<Breadcrumb>, + val remoteType: String?, + override val uuid: String = UUID.randomUUID().toString(), + ) : Crash() { + override fun toBundle() = Bundle().apply { + putString(INTENT_UUID, uuid) + putString(INTENT_MINIDUMP_PATH, minidumpPath) + putBoolean(INTENT_MINIDUMP_SUCCESS, minidumpSuccess) + putString(INTENT_EXTRAS_PATH, extrasPath) + putString(INTENT_PROCESS_TYPE, processType) + putLong(INTENT_CRASH_TIMESTAMP, timestamp) + putParcelableArrayList(INTENT_BREADCRUMBS, breadcrumbs) + putString(INTENT_REMOTE_TYPE, remoteType) + } + + /** + * Whether the crash was fatal or not: If true, the main application process was affected by + * the crash. If false, only an internal process used by Gecko has crashed and the application + * may be able to recover. + */ + val isFatal: Boolean + get() = processType == PROCESS_TYPE_MAIN + + companion object { + /** + * Indicates a crash occurred in the main process and is therefore fatal. + */ + const val PROCESS_TYPE_MAIN = "MAIN" + + /** + * Indicates a crash occurred in a foreground child process. The application may be + * able to recover from this crash, but it was likely noticeable to the user. + */ + const val PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD" + + /** + * Indicates a crash occurred in a background child process. This should have been + * recovered from automatically, and will have had minimal impact to the user, if any. + */ + const val PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD" + + @StringDef(PROCESS_TYPE_MAIN, PROCESS_TYPE_FOREGROUND_CHILD, PROCESS_TYPE_BACKGROUND_CHILD) + @Retention(AnnotationRetention.SOURCE) + annotation class ProcessType + + internal fun fromBundle(bundle: Bundle) = NativeCodeCrash( + uuid = bundle.getString(INTENT_UUID) ?: UUID.randomUUID().toString(), + minidumpPath = bundle.getString(INTENT_MINIDUMP_PATH, null), + minidumpSuccess = bundle.getBoolean(INTENT_MINIDUMP_SUCCESS, false), + extrasPath = bundle.getString(INTENT_EXTRAS_PATH, null), + processType = bundle.getString(INTENT_PROCESS_TYPE, PROCESS_TYPE_MAIN), + breadcrumbs = bundle.getParcelableArrayListCompat(INTENT_BREADCRUMBS, Breadcrumb::class.java) + ?: arrayListOf(), + remoteType = bundle.getString(INTENT_REMOTE_TYPE, null), + timestamp = bundle.getLong(INTENT_CRASH_TIMESTAMP, System.currentTimeMillis()), + ) + } + } + + internal abstract fun toBundle(): Bundle + + internal fun fillIn(intent: Intent) { + intent.putExtra(INTENT_CRASH, toBundle()) + } + + companion object { + fun fromIntent(intent: Intent): Crash { + val bundle = intent.getBundleExtra(INTENT_CRASH)!! + + return if (bundle.containsKey(INTENT_MINIDUMP_PATH)) { + NativeCodeCrash.fromBundle(bundle) + } else { + UncaughtExceptionCrash.fromBundle(bundle) + } + } + + fun isCrashIntent(intent: Intent) = intent.extras?.containsKey(INTENT_CRASH) ?: false + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt new file mode 100644 index 0000000000..74daaef197 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt @@ -0,0 +1,376 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import android.app.ActivityOptions +import android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.StyleRes +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.lib.crash.db.CrashDatabase +import mozilla.components.lib.crash.db.insertCrashSafely +import mozilla.components.lib.crash.db.insertReportSafely +import mozilla.components.lib.crash.db.toEntity +import mozilla.components.lib.crash.db.toReportEntity +import mozilla.components.lib.crash.handler.ExceptionHandler +import mozilla.components.lib.crash.notification.CrashNotification +import mozilla.components.lib.crash.prompt.CrashPrompt +import mozilla.components.lib.crash.service.CrashReporterService +import mozilla.components.lib.crash.service.CrashTelemetryService +import mozilla.components.lib.crash.service.SendCrashReportService +import mozilla.components.lib.crash.service.SendCrashTelemetryService +import mozilla.components.support.base.android.NotificationsDelegate +import mozilla.components.support.base.log.logger.Logger + +/** + * Stores a list of `Breadcrumb` objects for the crash reporter. + * + * This is shared between multiple threads and needs to be thread-safe. + */ +private class BreadcrumbList(val maxBreadCrumbs: Int) { + private val breadcrumbs = ArrayDeque<Breadcrumb>() + + @Synchronized + internal fun copy(): ArrayList<Breadcrumb> { + return ArrayList<Breadcrumb>(breadcrumbs) + } + + @Synchronized + internal fun add(breadcrumb: Breadcrumb) { + if (breadcrumbs.size >= maxBreadCrumbs) { + breadcrumbs.removeFirst() + } + breadcrumbs.add(breadcrumb) + } +} + +/** + * + * A generic crash reporter that can report crashes to multiple services. + * + * In the `onCreate()` method of your Application class create a `CrashReporter` instance and call `install()`: + * + * ```Kotlin + * CrashReporter( + * services = listOf( + * // List the crash reporting services you want to use + * ) + * ).install(this) + * ``` + * + * With this minimal setup the crash reporting library will capture "uncaught exception" crashes and "native code" + * crashes and forward them to the configured crash reporting services. + * + * @property enabled Enable/Disable crash reporting. + * + * @param services List of crash reporting services that should receive crash reports. + * @param telemetryServices List of telemetry crash reporting services that should receive crash reports. + * @param shouldPrompt Whether or not the user should be prompted to confirm sending crash reports. + * @param enabled Enable/Disable crash reporting. + * @param promptConfiguration Configuration for customizing the crash reporter prompt. + * @param nonFatalCrashIntent A [PendingIntent] that will be launched if a non fatal crash (main process not affected) + * happened. This gives the app the opportunity to show an in-app confirmation UI before + * sending a crash report. See component README for details. + */ +class CrashReporter( + context: Context, + private val services: List<CrashReporterService> = emptyList(), + private val telemetryServices: List<CrashTelemetryService> = emptyList(), + private val shouldPrompt: Prompt = Prompt.NEVER, + var enabled: Boolean = true, + internal val promptConfiguration: PromptConfiguration = PromptConfiguration(), + private val nonFatalCrashIntent: PendingIntent? = null, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val maxBreadCrumbs: Int = 30, + private val notificationsDelegate: NotificationsDelegate, +) : CrashReporting { + private val database: CrashDatabase by lazy { CrashDatabase.get(context) } + + internal val logger = Logger("mozac/CrashReporter") + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + private val crashBreadcrumbs = BreadcrumbList(maxBreadCrumbs) + + init { + if (services.isEmpty() and telemetryServices.isEmpty()) { + throw IllegalArgumentException("No crash reporter services defined") + } + } + + /** + * Install this [CrashReporter] instance. At this point the component will be setup to collect crash reports. + */ + fun install(applicationContext: Context): CrashReporter { + instance = this + + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + val handler = ExceptionHandler(applicationContext, this, defaultHandler) + Thread.setDefaultUncaughtExceptionHandler(handler) + + return this + } + + /** + * Get a copy of the crashBreadcrumbs + */ + fun crashBreadcrumbsCopy(): ArrayList<Breadcrumb> { + return crashBreadcrumbs.copy() + } + + /** + * Submit a crash report to all registered services. + */ + fun submitReport(crash: Crash, then: () -> Unit = {}): Job { + return scope.launch { + services.forEach { service -> + val reportId = when (crash) { + is Crash.NativeCodeCrash -> service.report(crash) + is Crash.UncaughtExceptionCrash -> service.report(crash) + } + + if (reportId != null) { + database.crashDao().insertReportSafely(service.toReportEntity(crash, reportId)) + } + + val reportUrl = reportId?.let { service.createCrashReportUrl(it) } + + logger.info("Submitted crash to ${service.name} (id=$reportId, url=$reportUrl)") + } + + logger.info("Crash report submitted to ${services.size} services") + withContext(Dispatchers.Main) { + then() + } + } + } + + /** + * Submit a crash report to all registered telemetry services. + */ + fun submitCrashTelemetry(crash: Crash, then: () -> Unit = {}): Job { + return scope.launch { + telemetryServices.forEach { telemetryService -> + when (crash) { + is Crash.NativeCodeCrash -> telemetryService.record(crash) + is Crash.UncaughtExceptionCrash -> telemetryService.record(crash) + } + } + + logger.info("Crash report submitted to ${telemetryServices.size} telemetry services") + withContext(Dispatchers.Main) { + then() + } + } + } + + /** + * Submit a caught exception report to all registered services. + */ + override fun submitCaughtException(throwable: Throwable): Job { + /* + * if stacktrace is empty, replace throwable with UnexpectedlyMissingStacktrace exception so + * we can figure out which module is submitting caught exception reports without a stacktrace. + */ + var reportThrowable = throwable + if (throwable.stackTrace.isEmpty()) { + reportThrowable = CrashReporterException.UnexpectedlyMissingStacktrace("Missing Stacktrace", throwable) + } + + logger.info("Caught Exception report submitted to ${services.size} services") + return scope.launch { + services.forEach { + it.report(reportThrowable, crashBreadcrumbsCopy()) + } + } + } + + /** + * Add a crash breadcrumb to all registered services with breadcrumb support. + * + * ```Kotlin + * crashReporter.recordCrashBreadcrumb( + * Breadcrumb("Settings button clicked", data, "UI", Level.INFO, Type.USER) + * ) + * ``` + */ + override fun recordCrashBreadcrumb(breadcrumb: Breadcrumb) { + crashBreadcrumbs.add(breadcrumb) + } + + internal fun onCrash(context: Context, crash: Crash) { + if (!enabled) { + return + } + + logger.info("Received crash: $crash") + + database.crashDao().insertCrashSafely(crash.toEntity()) + + if (telemetryServices.isNotEmpty()) { + sendCrashTelemetry(context, crash) + } + + // If crash is native code and non fatal then the view will handle the user prompt + if (shouldSendIntent(crash)) { + // App has registered a pending intent + sendNonFatalCrashIntent(context, crash) + return + } + + if (services.isNotEmpty()) { + if (CrashPrompt.shouldPromptForCrash(shouldPrompt, crash)) { + showPromptOrNotification(context, crash) + } else { + sendCrashReport(context, crash) + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendNonFatalCrashIntent(context: Context, crash: Crash) { + logger.info("Invoking non-fatal PendingIntent") + + val additionalIntent = Intent() + crash.fillIn(additionalIntent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val onFinished = null + val handler = null + val requiredPermission = null + val activityOptions = ActivityOptions.makeBasic() + activityOptions.pendingIntentBackgroundActivityStartMode = + MODE_BACKGROUND_ACTIVITY_START_ALLOWED + + nonFatalCrashIntent?.send( + context, + 0, + additionalIntent, + onFinished, + handler, + requiredPermission, + activityOptions.toBundle(), + ) + } else { + nonFatalCrashIntent?.send(context, 0, additionalIntent) + } + } + + private fun showPromptOrNotification(context: Context, crash: Crash) { + if (services.isEmpty()) { + return + } + + if (CrashNotification.shouldShowNotificationInsteadOfPrompt(crash)) { + // If this is a fatal crash taking down the app then we may not be able to show a crash reporter + // prompt on Android Q+. Unfortunately it's not possible to easily determine if we can launch an + // activity here. So instead we fallback to just showing a notification + // https://developer.android.com/preview/privacy/background-activity-starts + logger.info("Showing notification") + val notification = CrashNotification(context, crash, promptConfiguration, notificationsDelegate) + notification.show() + } else { + logger.info("Showing prompt") + showPrompt(context, crash) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendCrashReport(context: Context, crash: Crash) { + ContextCompat.startForegroundService(context, SendCrashReportService.createReportIntent(context, crash)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendCrashTelemetry(context: Context, crash: Crash) { + ContextCompat.startForegroundService(context, SendCrashTelemetryService.createReportIntent(context, crash)) + } + + @VisibleForTesting + internal fun showPrompt(context: Context, crash: Crash) { + val prompt = CrashPrompt(context, crash) + prompt.show() + } + + private fun shouldSendIntent(crash: Crash): Boolean { + return if (nonFatalCrashIntent == null) { + // If the app has not registered any intent then we can't send one. + false + } else { + // If this is a native code crash in a foreground child process then we can recover + // and can notify the app. Background child process crashes will be recovered from + // automatically, and main process crashes cannot be recovered from, so we do not + // send the intent for those. + crash is Crash.NativeCodeCrash && crash.processType == Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD + } + } + + internal fun getCrashReporterServiceById(id: String): CrashReporterService? { + return services.firstOrNull { it.id == id } + } + + enum class Prompt { + /** + * Never prompt the user. Always submit crash reports immediately. + */ + NEVER, + + /** + * Only prompt the user for native code crashes. + */ + ONLY_NATIVE_CRASH, + + /** + * Always prompt the user for confirmation before sending crash reports. + */ + ALWAYS, + } + + /** + * Configuration for the crash reporter prompt. + */ + data class PromptConfiguration( + internal val appName: String = "App", + internal val organizationName: String = "Mozilla", + internal val message: String? = null, + @StyleRes internal val theme: Int = R.style.Theme_Mozac_CrashReporter, + ) + + companion object { + @Volatile + private var instance: CrashReporter? = null + + @VisibleForTesting + internal fun reset() { + instance = null + } + + internal val requireInstance: CrashReporter + get() = instance ?: throw IllegalStateException( + "You need to call install() on your CrashReporter instance from Application.onCreate().", + ) + } +} + +/** + * A base class for exceptions describing crash reporter exception. + */ +internal abstract class CrashReporterException(message: String, cause: Throwable?) : Exception(message, cause) { + /** + * Stacktrace was expected to be present, but it wasn't. + */ + internal class UnexpectedlyMissingStacktrace( + message: String, + cause: Throwable?, + ) : CrashReporterException(message, cause) +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt new file mode 100644 index 0000000000..48dcf7aefe --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.db + +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import java.lang.Exception + +/** + * Dao for saving and accessing crash related information. + */ +@Dao +internal interface CrashDao { + /** + * Inserts a crash into the database. + */ + @Insert + fun insertCrash(crash: CrashEntity): Long + + /** + * Inserts a report to the database. + */ + @Insert + fun insertReport(report: ReportEntity): Long + + /** + * Returns saved crashes with their reports. + */ + @Transaction + @Query("SELECT * FROM crashes ORDER BY created_at DESC") + fun getCrashesWithReports(): LiveData<List<CrashWithReports>> + + /** + * Delete table. + */ + @Transaction + @Query("DELETE FROM crashes") + fun deleteAll() +} + +/** + * Insert crash into database safely, ignoring any exceptions. + * + * When handling a crash we want to avoid causing another crash when writing to the database. In the + * case of an error we will just ignore it and continue without saving to the database. + */ +@SuppressLint("LogUsage") // We do not want to use our custom logger while handling the crash +@Suppress("TooGenericExceptionCaught") +internal fun CrashDao.insertCrashSafely(entity: CrashEntity) { + try { + insertCrash(entity) + } catch (e: Exception) { + Log.e("CrashDao", "Failed to insert crash into database", e) + } +} + +/** + * Insert report into database safely, ignoring any exceptions. + * + * When handling a crash we want to avoid causing another crash when writing to the database. In the + * case of an error we will just ignore it and continue without saving to the database. + */ +@SuppressLint("LogUsage") // We do not want to use our custom logger while handling the crash +@Suppress("TooGenericExceptionCaught") +internal fun CrashDao.insertReportSafely(entity: ReportEntity) { + try { + insertReport(entity) + } catch (e: Exception) { + Log.e("CrashDao", "Failed to insert report into database", e) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDatabase.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDatabase.kt new file mode 100644 index 0000000000..2a7ac4e4e3 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDatabase.kt @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * Internal database for storing collections and their tabs. + */ +@Database( + entities = [CrashEntity::class, ReportEntity::class], + version = 1, +) +internal abstract class CrashDatabase : RoomDatabase() { + abstract fun crashDao(): CrashDao + + companion object { + @Volatile private var instance: CrashDatabase? = null + + @Synchronized + fun get(context: Context): CrashDatabase { + instance?.let { return it } + + return Room.databaseBuilder(context.applicationContext, CrashDatabase::class.java, "crashes") + // We are allowing main thread queries here since we need to write to disk blocking + // in a crash event before the process gets shutdown. At this point the app already + // crashed and temporarily blocking the UI thread is not that problematic anymore. + .allowMainThreadQueries() + .build() + .also { + instance = it + } + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashEntity.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashEntity.kt new file mode 100644 index 0000000000..26ba0b0991 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashEntity.kt @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import mozilla.components.lib.crash.Crash +import mozilla.components.support.base.ext.getStacktraceAsString + +/** + * Database entity modeling a crash that has happened. + */ +@Entity( + tableName = "crashes", +) +internal data class CrashEntity( + /** + * Generated UUID for this crash. + */ + @PrimaryKey + @ColumnInfo(name = "uuid") + var uuid: String, + + /** + * The stacktrace of the crash (if this crash was caused by an exception/throwable): otherwise + * a string describing the type of crash. + */ + @ColumnInfo(name = "stacktrace") + var stacktrace: String, + + /** + * Timestamp (in milliseconds) of when the crash happened. + */ + @ColumnInfo(name = "created_at") + var createdAt: Long, +) + +internal fun Crash.toEntity(): CrashEntity { + return CrashEntity( + uuid = uuid, + stacktrace = getStacktrace(), + createdAt = System.currentTimeMillis(), + ) +} + +internal fun Crash.getStacktrace(): String { + return when (this) { + is Crash.NativeCodeCrash -> "<native crash>" + is Crash.UncaughtExceptionCrash -> throwable.getStacktraceAsString() + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashWithReports.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashWithReports.kt new file mode 100644 index 0000000000..079b283168 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashWithReports.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.db + +import androidx.room.Embedded +import androidx.room.Relation + +/** + * Data class modelling the relationship between [CrashEntity] and [ReportEntity] objects. + */ +internal data class CrashWithReports( + @Embedded + val crash: CrashEntity, + + @Relation( + parentColumn = "uuid", + entityColumn = "crash_uuid", + ) + val reports: List<ReportEntity>, +) diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt new file mode 100644 index 0000000000..5136a8526e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.service.CrashReporterService + +/** + * Datanase entry describing a crash report that was sent to a crash reporting service. + */ +@Entity( + tableName = "reports", +) +internal data class ReportEntity( + /** + * Database internal primary key of the entry. + */ + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + var id: Long? = null, + + /** + * UUID of the crash that was reported. + */ + @ColumnInfo(name = "crash_uuid") + var crashUuid: String, + + /** + * Id of the service the crash was reported to (matching [CrashReporterService.id]. + */ + @ColumnInfo(name = "service_id") + var serviceId: String, + + /** + * The id of the crash report as returned by [CrashReporterService.report]. + */ + @ColumnInfo(name = "report_id") + var reportId: String, +) + +internal fun CrashReporterService.toReportEntity(crash: Crash, reportId: String): ReportEntity { + return ReportEntity( + crashUuid = crash.uuid, + serviceId = id, + reportId = reportId, + ) +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/CrashHandlerService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/CrashHandlerService.kt new file mode 100644 index 0000000000..2c21298412 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/CrashHandlerService.kt @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.handler + +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.notification.CrashNotification +import mozilla.components.support.base.ids.SharedIdsHelper + +private const val NOTIFICATION_TAG = "mozac.lib.crash.handlecrash" + +/** + * Service receiving native code crashes (from GeckoView). + */ +class CrashHandlerService : Service() { + private val crashReporter: CrashReporter by lazy { CrashReporter.requireInstance } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + crashReporter.logger.error("CrashHandlerService received native code crash") + handleCrashIntent(intent) + } else { + crashReporter.logger.error("CrashHandlerService received a null intent unable to handle") + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + // We don't provide binding, so return null + return null + } + + @VisibleForTesting(otherwise = PRIVATE) + internal fun handleCrashIntent( + intent: Intent, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = CrashNotification.ensureChannelExists(this) + val notification = NotificationCompat.Builder(this, channel) + .setContentTitle( + getString(R.string.mozac_lib_gathering_crash_data_in_progress), + ) + .setSmallIcon(R.drawable.mozac_lib_crash_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .build() + + val notificationId = SharedIdsHelper.getIdForTag(this, NOTIFICATION_TAG) + startForeground(notificationId, notification) + } + + scope.launch { + intent.extras?.let { extras -> + val crash = Crash.NativeCodeCrash.fromBundle(extras) + CrashReporter.requireInstance.onCrash(this@CrashHandlerService, crash) + } ?: crashReporter.logger.error("Received intent with null extras") + + stopSelf() + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt new file mode 100644 index 0000000000..1c764da8fe --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.handler + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Process +import android.util.Log +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter + +private const val TAG = "ExceptionHandler" + +/** + * [Thread.UncaughtExceptionHandler] implementation that forwards crashes to the [CrashReporter] instance. + */ +class ExceptionHandler( + private val context: Context, + private val crashReporter: CrashReporter, + private val defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null, +) : Thread.UncaughtExceptionHandler { + private var crashing = false + + @SuppressLint("LogUsage") // We do not want to use our custom logger while handling the crash + override fun uncaughtException(thread: Thread, throwable: Throwable) { + Log.e(TAG, "Uncaught exception handled: ", throwable) + + if (crashing) { + return + } + + // We want to catch and log all exceptions that can take down the crash reporter. + // This is the best we can do without being able to report it. + @Suppress("TooGenericExceptionCaught") + try { + crashing = true + + crashReporter.onCrash( + context, + Crash.UncaughtExceptionCrash( + timestamp = System.currentTimeMillis(), + throwable = throwable, + breadcrumbs = crashReporter.crashBreadcrumbsCopy(), + ), + ) + + defaultExceptionHandler?.uncaughtException(thread, throwable) + } catch (e: Exception) { + Log.e(TAG, "Crash reporter has crashed.", e) + } finally { + terminateProcess() + } + } + + private fun terminateProcess() { + Process.killProcess(Process.myPid()) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt new file mode 100644 index 0000000000..73b4c0c789 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.prompt.CrashPrompt +import mozilla.components.support.base.android.NotificationsDelegate +import mozilla.components.support.base.ids.SharedIdsHelper +import mozilla.components.support.utils.PendingIntentUtils + +private const val NOTIFICATION_SDK_LEVEL = 29 // On Android Q+ we show a notification instead of a prompt + +internal const val NOTIFICATION_TAG = "mozac.lib.crash.notification" +internal const val NOTIFICATION_ID = 1 +private const val NOTIFICATION_CHANNEL_ID = "mozac.lib.crash.channel" +private const val PENDING_INTENT_TAG = "mozac.lib.crash.pendingintent" + +internal class CrashNotification( + private val context: Context, + private val crash: Crash, + private val configuration: CrashReporter.PromptConfiguration, + private val notificationsDelegate: NotificationsDelegate, +) { + fun show() { + val pendingIntent = PendingIntent.getActivity( + context, + SharedIdsHelper.getNextIdForTag(context, PENDING_INTENT_TAG), + CrashPrompt.createIntent(context, crash), + getNotificationFlag(), + ) + + val channel = ensureChannelExists(context) + + val title = if (crash is Crash.NativeCodeCrash && + crash.processType == Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD + ) { + context.getString( + R.string.mozac_lib_crash_background_process_notification_title, + configuration.appName, + ) + } else { + context.getString(R.string.mozac_lib_crash_dialog_title, configuration.appName) + } + + val notification = NotificationCompat.Builder(context, channel) + .setContentTitle(title) + .setSmallIcon(R.drawable.mozac_lib_crash_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentIntent(pendingIntent) + .addAction( + R.drawable.mozac_lib_crash_notification, + context.getString( + R.string.mozac_lib_crash_notification_action_report, + ), + pendingIntent, + ) + .setAutoCancel(true) + .build() + + notificationsDelegate.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notification) + } + + companion object { + /** + * Whether to show a notification instead of a prompt (activity). Android introduced restrictions on background + * services launching activities in Q+. On those system we may need to show a notification for the given [crash] + * and launch the reporter from the notification. + */ + fun shouldShowNotificationInsteadOfPrompt( + crash: Crash, + sdkLevel: Int = Build.VERSION.SDK_INT, + ): Boolean { + return when { + // We can always launch an activity from a background service pre Android Q. + sdkLevel < NOTIFICATION_SDK_LEVEL -> false + + // We may not be able to launch an activity if a background process crash occurs + // while the application is in the background. + crash is Crash.NativeCodeCrash && crash.processType == + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD -> true + + // An uncaught exception is crashing the app and we may not be able to launch an activity from here. + crash is Crash.UncaughtExceptionCrash -> true + + // This is a fatal native crash. We may not be able to launch an activity from here. + else -> crash is Crash.NativeCodeCrash && crash.isFatal + } + } + + fun ensureChannelExists(context: Context): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager: NotificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE, + ) as NotificationManager + + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + context.getString(R.string.mozac_lib_crash_channel), + NotificationManager.IMPORTANCE_DEFAULT, + ) + + notificationManager.createNotificationChannel(channel) + } + + return NOTIFICATION_CHANNEL_ID + } + } + + private fun getNotificationFlag() = PendingIntentUtils.defaultFlags +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/prompt/CrashPrompt.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/prompt/CrashPrompt.kt new file mode 100644 index 0000000000..dcdf2a7a2e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/prompt/CrashPrompt.kt @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.prompt + +import android.content.Context +import android.content.Intent +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter + +internal class CrashPrompt( + private val context: Context, + private val crash: Crash, +) { + fun show() { + context.startActivity(createIntent(context, crash)) + } + + companion object { + fun createIntent(context: Context, crash: Crash): Intent { + val intent = Intent(context, CrashReporterActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + // For background process native crashes we want to keep the browser visible in the + // background behind the prompt. For other types we want to clear the existing task. + if (crash is Crash.NativeCodeCrash && + crash.processType == Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD + ) { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } else { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + + crash.fillIn(intent) + + return intent + } + + fun shouldPromptForCrash(shouldPrompt: CrashReporter.Prompt, crash: Crash): Boolean { + return when (shouldPrompt) { + CrashReporter.Prompt.ALWAYS -> true + CrashReporter.Prompt.NEVER -> false + CrashReporter.Prompt.ONLY_NATIVE_CRASH -> crash is Crash.NativeCodeCrash + } + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/prompt/CrashReporterActivity.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/prompt/CrashReporterActivity.kt new file mode 100644 index 0000000000..fad866f139 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/prompt/CrashReporterActivity.kt @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.prompt + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.NotificationManagerCompat +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.databinding.MozacLibCrashCrashreporterBinding +import mozilla.components.lib.crash.notification.CrashNotification +import mozilla.components.lib.crash.notification.NOTIFICATION_ID +import mozilla.components.lib.crash.notification.NOTIFICATION_TAG +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Activity showing the crash reporter prompt asking the user for confirmation before submitting a crash report. + */ +class CrashReporterActivity : AppCompatActivity() { + + private val crashReporter: CrashReporter by lazy { CrashReporter.requireInstance } + private val crash: Crash by lazy { Crash.fromIntent(intent) } + private val sharedPreferences by lazy { + getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + } + + /** + * Coroutine context for crash reporter operations. Can be used to setup dispatcher for tests. + */ + @VisibleForTesting(otherwise = PRIVATE) + internal var reporterCoroutineContext: CoroutineContext = EmptyCoroutineContext + + @VisibleForTesting(otherwise = PRIVATE) + internal lateinit var binding: MozacLibCrashCrashreporterBinding + + override fun onCreate(savedInstanceState: Bundle?) { + // if the activity is started by user tapping on the crash notification's report button, + // remove the crash notification. + if (CrashNotification.shouldShowNotificationInsteadOfPrompt(crash)) { + NotificationManagerCompat.from(applicationContext).cancel(NOTIFICATION_TAG, NOTIFICATION_ID) + } + + setTheme(crashReporter.promptConfiguration.theme) + + super.onCreate(savedInstanceState) + + binding = MozacLibCrashCrashreporterBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupViews() + } + + private fun setupViews() { + val appName = crashReporter.promptConfiguration.appName + val organizationName = crashReporter.promptConfiguration.organizationName + + binding.titleView.text = when (isRecoverableBackgroundCrash(crash)) { + true -> getString( + R.string.mozac_lib_crash_background_process_notification_title, + appName, + ) + false -> getString(R.string.mozac_lib_crash_dialog_title, appName) + } + + binding.sendCheckbox.text = getString(R.string.mozac_lib_crash_dialog_checkbox, organizationName) + binding.sendCheckbox.isChecked = sharedPreferences.getBoolean(PREFERENCE_KEY_SEND_REPORT, true) + + binding.restartButton.apply { + text = getString(R.string.mozac_lib_crash_dialog_button_restart, appName) + setOnClickListener { restart() } + } + binding.closeButton.setOnClickListener { close() } + + // For background crashes show just the close button. Otherwise show close and restart. + if (isRecoverableBackgroundCrash(crash)) { + binding.restartButton.visibility = View.GONE + val closeButtonParams = binding.closeButton.layoutParams as ConstraintLayout.LayoutParams + closeButtonParams.startToStart = ConstraintLayout.LayoutParams.UNSET + closeButtonParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + } else { + binding.restartButton.visibility = View.VISIBLE + val closeButtonParams = binding.closeButton.layoutParams as ConstraintLayout.LayoutParams + closeButtonParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID + closeButtonParams.endToEnd = ConstraintLayout.LayoutParams.UNSET + } + + if (crashReporter.promptConfiguration.message == null) { + binding.messageView.visibility = View.GONE + } else { + binding.messageView.text = crashReporter.promptConfiguration.message + } + } + + private fun close() { + sendCrashReportIfNeeded { + finish() + } + } + + private fun restart() { + sendCrashReportIfNeeded { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent != null) { + launchIntent.flags = launchIntent.flags or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(launchIntent) + } + + finish() + } + } + + private fun sendCrashReportIfNeeded(then: () -> Unit) { + sharedPreferences.edit().putBoolean(PREFERENCE_KEY_SEND_REPORT, binding.sendCheckbox.isChecked).apply() + + if (!binding.sendCheckbox.isChecked) { + then() + return + } + + crashReporter.submitReport(crash) { + then() + } + } + + override fun onBackPressed() { + sendCrashReportIfNeeded { + finish() + } + } + + /* + * Return true if the crash occurred in the background and is recoverable. (ex: GPU process crash) + */ + @VisibleForTesting + internal fun isRecoverableBackgroundCrash(crash: Crash): Boolean { + return crash is Crash.NativeCodeCrash && + crash.processType == Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD + } + + companion object { + + @VisibleForTesting(otherwise = PRIVATE) + internal const val SHARED_PREFERENCES_NAME = "mozac_lib_crash_settings" + + @VisibleForTesting(otherwise = PRIVATE) + internal const val PREFERENCE_KEY_SEND_REPORT = "sendCrashReport" + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt new file mode 100644 index 0000000000..698d33128b --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.Crash + +const val LIB_CRASH_INFO_PREFIX = "[INFO]" + +/** + * Interface to be implemented by external services that accept crash reports. + */ +interface CrashReporterService { + /** + * A unique ID to identify this crash reporter service. + */ + val id: String + + /** + * A human-readable name for this crash reporter service (to be displayed in UI). + */ + val name: String + + /** + * Returns a URL to a website with the crash report if possible. Otherwise returns null. + */ + fun createCrashReportUrl(identifier: String): String? + + /** + * Submits a crash report for this [Crash.UncaughtExceptionCrash]. + * + * @return Unique crash report identifier that can be used by/with this crash reporter service + * to find this reported crash - or null if no identifier can be provided. + */ + fun report(crash: Crash.UncaughtExceptionCrash): String? + + /** + * Submits a crash report for this [Crash.NativeCodeCrash]. + * + * @return Unique crash report identifier that can be used by/with this crash reporter service + * to find this reported crash - or null if no identifier can be provided. + */ + fun report(crash: Crash.NativeCodeCrash): String? + + /** + * Submits a caught exception report for this [Throwable]. + * + * @return Unique crash report identifier that can be used by/with this crash reporter service + * to find this reported crash - or null if no identifier can be provided. + */ + fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashTelemetryService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashTelemetryService.kt new file mode 100644 index 0000000000..ce85fee75e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashTelemetryService.kt @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import mozilla.components.lib.crash.Crash + +/** + * Interface to be implemented by external services that collect telemetry about crash reports. + */ +interface CrashTelemetryService { + /** + * Records telemetry for this [Crash.UncaughtExceptionCrash]. + */ + fun record(crash: Crash.UncaughtExceptionCrash) + + /** + * Records telemetry for this [Crash.NativeCodeCrash]. + */ + fun record(crash: Crash.NativeCodeCrash) + + /** + * Records telemetry for this caught [Throwable] (non-crash). + */ + fun record(throwable: Throwable) +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/GleanCrashReporterService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/GleanCrashReporterService.kt new file mode 100644 index 0000000000..be6b411816 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/GleanCrashReporterService.kt @@ -0,0 +1,312 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.content.Context +import android.os.SystemClock +import androidx.annotation.VisibleForTesting +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.DecodeSequenceMode +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeToSequence +import kotlinx.serialization.json.encodeToStream +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.GleanMetrics.CrashMetrics +import mozilla.components.lib.crash.GleanMetrics.Pings +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.content.isMainProcess +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.Date +import mozilla.components.lib.crash.GleanMetrics.Crash as GleanCrash + +/** + * A [CrashReporterService] implementation for recording metrics with Glean. The purpose of this + * crash reporter is to collect crash count metrics by capturing [Crash.UncaughtExceptionCrash], + * [Throwable] and [Crash.NativeCodeCrash] events and record to the respective + * [mozilla.components.service.glean.private.CounterMetricType]. + */ +class GleanCrashReporterService( + val context: Context, + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val file: File = File(context.applicationInfo.dataDir, CRASH_FILE_NAME), +) : CrashTelemetryService { + companion object { + // This file is stored in the application's data directory, so it should be located in the + // same location as the application. + // The format of this file is simple and uses the keys named below, one per line, to record + // crashes. That format allows for multiple crashes to be appended to the file if, for some + // reason, the application cannot run and record them. + const val CRASH_FILE_NAME = "glean_crash_counts" + + // These keys correspond to the labels found for crashCount metric in metrics.yaml as well + // as the persisted crashes in the crash count file (see above comment) + const val UNCAUGHT_EXCEPTION_KEY = "uncaught_exception" + const val CAUGHT_EXCEPTION_KEY = "caught_exception" + const val MAIN_PROCESS_NATIVE_CODE_CRASH_KEY = "main_proc_native_code_crash" + const val FOREGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY = "fg_proc_native_code_crash" + const val BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY = "bg_proc_native_code_crash" + + // These keys are deprecated and should be removed after a period to allow for persisted + // crashes to be submitted. + const val FATAL_NATIVE_CODE_CRASH_KEY = "fatal_native_code_crash" + const val NONFATAL_NATIVE_CODE_CRASH_KEY = "nonfatal_native_code_crash" + } + + /** + * The subclasses of GleanCrashAction are used to persist Glean actions to handle them later + * (in the application which has Glean initialized). They are serialized to JSON objects and + * appended to a file, in case multiple crashes occur prior to being able to submit the metrics + * to Glean. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + @Serializable + internal sealed class GleanCrashAction { + /** + * Submit the glean metrics/pings. + */ + abstract fun submit() + + @Serializable + @SerialName("count") + data class Count(val label: String) : GleanCrashAction() { + override fun submit() { + CrashMetrics.crashCount[label].add() + } + } + + @Serializable + @SerialName("ping") + data class Ping( + val uptimeNanos: Long, + val processType: String, + val timeMillis: Long, + val startup: Boolean, + val reason: Pings.crashReasonCodes, + val cause: String = "os_fault", + val remoteType: String = "", + ) : GleanCrashAction() { + override fun submit() { + GleanCrash.uptime.setRawNanos(uptimeNanos) + GleanCrash.processType.set(processType) + GleanCrash.remoteType.set(remoteType) + GleanCrash.time.set(Date(timeMillis)) + GleanCrash.startup.set(startup) + GleanCrash.cause.set(cause) + Pings.crash.submit(reason) + } + } + } + + private val logger = Logger("glean/GleanCrashReporterService") + private val creationTime = SystemClock.elapsedRealtimeNanos() + + init { + run { + // We only want to record things on the main process because that is the only one in which + // Glean is properly initialized. Checking to see if we are on the main process here will + // prevent the situation that arises because the integrating app's Application will be + // re-created when prompting to report the crash, and Glean is not initialized there since + // it's not technically the main process. + if (!context.isMainProcess()) { + logger.info("GleanCrashReporterService initialized off of main process") + return@run + } + + if (!checkFileConditions()) { + // checkFileConditions() internally logs error conditions + return@run + } + + // Parse the persisted crashes + parseCrashFile() + + // Clear persisted counts by deleting the file + file.delete() + } + } + + /** + * Calculates the application uptime based on the creation time of this class (assuming it is + * created in the application's `OnCreate`). + */ + private fun uptime() = SystemClock.elapsedRealtimeNanos() - creationTime + + /** + * Checks the file conditions to ensure it can be opened and read. + * + * @return True if the file exists and is able to be read, otherwise false + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun checkFileConditions(): Boolean { + return if (!file.exists()) { + // This is just an info line, as most of the time we hope there is no file which means + // there were no crashes + logger.info("No crashes to record, or file not found.") + false + } else if (!file.canRead()) { + logger.error("Cannot read file") + false + } else if (!file.isFile) { + logger.error("Expected file, but found directory") + false + } else { + true + } + } + + /** + * Parses the crashes collected in the persisted crash file. The format of this file is simple, + * a stream of serialized JSON GleanCrashAction objects. + * + * Example: + * + * <--Beginning of file--> + * {"type":"count","label":"uncaught_exception"}\n + * {"type":"count","label":"uncaught_exception"}\n + * {"type":"count","label":"main_process_native_code_crash"}\n + * {"type":"ping","uptimeNanos":2000000,"processType":"main","timeMillis":42000000000, + * "startup":false}\n + * <--End of file--> + * + * It is unlikely that there will be more than one crash in a file, but not impossible. This + * could happen, for instance, if the application crashed again before the file could be + * processed. + */ + @Suppress("ComplexMethod") + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun parseCrashFile() { + try { + @OptIn(ExperimentalSerializationApi::class) + val actionSequence = Json.decodeToSequence<GleanCrashAction>( + file.inputStream(), + DecodeSequenceMode.WHITESPACE_SEPARATED, + ) + for (action in actionSequence) { + action.submit() + } + } catch (e: IOException) { + logger.error("Error reading crash file", e) + return + } catch (e: SerializationException) { + logger.error("Error deserializing crash file", e) + return + } + } + + /** + * This function handles the actual recording of the crash to the persisted crash file. We are + * only guaranteed runtime for the lifetime of the [CrashReporterService.report] function, + * anything that we do in this function **MUST** be synchronous and blocking. We cannot spawn + * work to background processes or threads here if we want to guarantee that the work is + * completed. Also, since the [CrashReporterService.report] functions are called synchronously, + * and from lib-crash's own process, it is unlikely that this would be called from more than one + * place at the same time. + * + * @param action Pass in the crash action to record. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun recordCrashAction(action: GleanCrashAction) { + // Persist the crash in a file so that it can be recorded on the next application start. We + // cannot directly record to Glean here because CrashHandler process is not the same process + // as Glean is initialized in. + // Create the file if it doesn't exist + if (!file.exists()) { + try { + file.createNewFile() + } catch (e: IOException) { + logger.error("Failed to create crash file", e) + } + } + + // Add a line representing the crash that was received + if (file.canWrite()) { + try { + @OptIn(ExperimentalSerializationApi::class) + Json.encodeToStream(action, FileOutputStream(file, true)) + file.appendText("\n") + } catch (e: IOException) { + logger.error("Failed to write to crash file", e) + } + } + } + + override fun record(crash: Crash.UncaughtExceptionCrash) { + recordCrashAction(GleanCrashAction.Count(UNCAUGHT_EXCEPTION_KEY)) + recordCrashAction( + GleanCrashAction.Ping( + uptimeNanos = uptime(), + processType = "main", + remoteType = "", + timeMillis = crash.timestamp, + startup = false, + reason = Pings.crashReasonCodes.crash, + cause = "java_exception", + ), + ) + } + + override fun record(crash: Crash.NativeCodeCrash) { + when (crash.processType) { + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN -> + recordCrashAction(GleanCrashAction.Count(MAIN_PROCESS_NATIVE_CODE_CRASH_KEY)) + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD -> + recordCrashAction( + GleanCrashAction.Count( + FOREGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY, + ), + ) + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD -> + recordCrashAction( + GleanCrashAction.Count( + BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY, + ), + ) + } + + // The `processType` property on a crash is a bit confusing because it does not map to the actual process types + // (like main, content, gpu, etc.). This property indicates what UI we should show to users given that "main" + // crashes essentially kill the app, "foreground child" crashes are likely tab crashes, and "background child" + // crashes are occurring in other processes (like GPU and extensions) for which users shouldn't notice anything + // (because there shouldn't be any noticeable impact in the app and the processes will be recreated + // automatically). + val processType = when (crash.processType) { + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN -> "main" + + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD -> { + when (crash.remoteType) { + // The extensions process is a content process as per: + // https://firefox-source-docs.mozilla.org/dom/ipc/process_model.html#webextensions + "extension" -> "content" + + else -> "utility" + } + } + + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD -> "content" + + else -> "main" + } + recordCrashAction( + GleanCrashAction.Ping( + uptimeNanos = uptime(), + processType = processType, + remoteType = crash.remoteType ?: "", + timeMillis = crash.timestamp, + startup = false, + reason = Pings.crashReasonCodes.crash, + cause = "os_fault", + ), + ) + } + + override fun record(throwable: Throwable) { + recordCrashAction(GleanCrashAction.Count(CAUGHT_EXCEPTION_KEY)) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt new file mode 100644 index 0000000000..df50580b0c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt @@ -0,0 +1,566 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.app.ActivityManager +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.annotation.VisibleForTesting +import androidx.core.content.pm.PackageInfoCompat +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.Crash +import mozilla.components.support.base.ext.getStacktraceAsJsonString +import mozilla.components.support.base.ext.getStacktraceAsString +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.utils.ext.getPackageInfoCompat +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileReader +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.channels.Channels +import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPOutputStream +import kotlin.random.Random +import mozilla.components.Build as AcBuild + +/* This ID is used for all Mozilla products. Setting as default if no ID is passed in */ +private const val MOZILLA_PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}" + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal const val CAUGHT_EXCEPTION_TYPE = "caught exception" +internal const val UNCAUGHT_EXCEPTION_TYPE = "uncaught exception" +internal const val FATAL_NATIVE_CRASH_TYPE = "fatal native crash" +internal const val NON_FATAL_NATIVE_CRASH_TYPE = "non-fatal native crash" + +internal const val DEFAULT_VERSION_NAME = "N/A" +internal const val DEFAULT_VERSION_CODE = "N/A" +internal const val DEFAULT_VERSION = "N/A" +internal const val DEFAULT_BUILD_ID = "N/A" +internal const val DEFAULT_VENDOR = "N/A" +internal const val DEFAULT_RELEASE_CHANNEL = "N/A" +internal const val DEFAULT_DISTRIBUTION_ID = "N/A" + +private const val KEY_CRASH_ID = "CrashID" + +private const val MINI_DUMP_FILE_EXT = "dmp" +private const val EXTRAS_FILE_EXT = "extra" +private const val FILE_REGEX = "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\\." + +/** + * A [CrashReporterService] implementation uploading crash reports to crash-stats.mozilla.com. + * + * @param applicationContext The application [Context]. + * @param appName A human-readable app name. This name is used on crash-stats.mozilla.com to filter crashes by app. + * The name needs to be safelisted for the server to accept the crash. + * [File a bug](https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro) if you would like to get your + * app added to the safelist. + * @param appId The application ID assigned by Socorro server. + * @param version The engine version. + * @param buildId The engine build ID. + * @param vendor The application vendor name. + * @param serverUrl The URL of the server. + * @param versionName The version of the application. + * @param versionCode The version code of the application. + * @param releaseChannel The release channel of the application. + * @param distributionId The distribution id of the application. + */ +@Suppress("LargeClass") +class MozillaSocorroService( + private val applicationContext: Context, + private val appName: String, + private val appId: String = MOZILLA_PRODUCT_ID, + private val version: String = DEFAULT_VERSION, + private val buildId: String = DEFAULT_BUILD_ID, + private val vendor: String = DEFAULT_VENDOR, + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var serverUrl: String? = null, + private var versionName: String = DEFAULT_VERSION_NAME, + private var versionCode: String = DEFAULT_VERSION_CODE, + private val releaseChannel: String = DEFAULT_RELEASE_CHANNEL, + private val distributionId: String = DEFAULT_DISTRIBUTION_ID, +) : CrashReporterService { + private val logger = Logger("mozac/MozillaSocorroCrashHelperService") + private val startTime = System.currentTimeMillis() + private val ignoreKeys = hashSetOf("URL", "ServerURL", "StackTraces") + + override val id: String = "socorro" + + override val name: String = "Socorro" + + override fun createCrashReportUrl(identifier: String): String? { + return "https://crash-stats.mozilla.org/report/index/$identifier" + } + + init { + val packageInfo = try { + applicationContext.packageManager.getPackageInfoCompat(applicationContext.packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + logger.error("package name not found, failed to get application version") + null + } + + packageInfo?.let { + if (versionName == DEFAULT_VERSION_NAME) { + try { + versionName = packageInfo.versionName ?: DEFAULT_VERSION_NAME + } catch (e: IllegalStateException) { + logger.error("failed to get application version") + } + } + + if (versionCode == DEFAULT_VERSION_CODE) { + try { + versionCode = PackageInfoCompat.getLongVersionCode(packageInfo).toString() + } catch (e: IllegalStateException) { + logger.error("failed to get application version code") + } + } + } + + if (serverUrl == null) { + serverUrl = Uri.parse("https://crash-reports.mozilla.com/submit") + .buildUpon() + .appendQueryParameter("id", appId) + .appendQueryParameter("version", versionName) + .appendQueryParameter("android_component_version", AcBuild.version) + .build().toString() + } + } + + override fun report(crash: Crash.UncaughtExceptionCrash): String? { + return sendReport( + crash.timestamp, + crash.throwable, + miniDumpFilePath = null, + extrasFilePath = null, + isNativeCodeCrash = false, + isFatalCrash = true, + breadcrumbs = crash.breadcrumbs, + ) + } + + override fun report(crash: Crash.NativeCodeCrash): String? { + return sendReport( + crash.timestamp, + throwable = null, + miniDumpFilePath = crash.minidumpPath, + extrasFilePath = crash.extrasPath, + isNativeCodeCrash = true, + isFatalCrash = crash.isFatal, + breadcrumbs = crash.breadcrumbs, + ) + } + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? { + /* Not sending caught exceptions to Socorro */ + return null + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendReport( + timestamp: Long, + throwable: Throwable?, + miniDumpFilePath: String?, + extrasFilePath: String?, + isNativeCodeCrash: Boolean, + isFatalCrash: Boolean, + breadcrumbs: ArrayList<Breadcrumb>, + ): String? { + val url = URL(serverUrl) + val boundary = generateBoundary() + var conn: HttpURLConnection? = null + + val breadcrumbsJson = JSONArray() + for (breadcrumb in breadcrumbs) { + breadcrumbsJson.put(breadcrumb.toJson()) + } + + try { + conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.doOutput = true + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + conn.setRequestProperty("Content-Encoding", "gzip") + + sendCrashData( + conn.outputStream, boundary, timestamp, throwable, miniDumpFilePath, extrasFilePath, + isNativeCodeCrash, isFatalCrash, breadcrumbsJson.toString(), + ) + + BufferedReader(InputStreamReader(conn.inputStream)).use { reader -> + val map = parseResponse(reader) + + val id = map?.get(KEY_CRASH_ID) + + if (id != null) { + logger.info("Crash reported to Socorro: $id") + } else { + logger.info("Server rejected crash report") + } + + return id + } + } catch (e: IOException) { + try { + logger.error("failed to send report to Socorro with " + conn?.responseCode, e) + } catch (e: IOException) { + logger.error("failed to send report to Socorro", e) + } + + return null + } finally { + conn?.disconnect() + } + } + + private fun parseResponse(reader: BufferedReader): Map<String, String>? { + val map = mutableMapOf<String, String>() + + reader.readLines().forEach { line -> + val position = line.indexOf("=") + if (position != -1) { + val key = line.substring(0, position) + val value = unescape(line.substring(position + 1)) + map[key] = value + } + } + + return map + } + + @Suppress("LongParameterList", "LongMethod", "ComplexMethod") + private fun sendCrashData( + os: OutputStream, + boundary: String, + timestamp: Long, + throwable: Throwable?, + miniDumpFilePath: String?, + extrasFilePath: String?, + isNativeCodeCrash: Boolean, + isFatalCrash: Boolean, + breadcrumbs: String, + ) { + val nameSet = mutableSetOf<String>() + val gzipOs = GZIPOutputStream(os) + sendPart(gzipOs, boundary, "ProductName", appName, nameSet) + sendPart(gzipOs, boundary, "ProductID", appId, nameSet) + sendPart(gzipOs, boundary, "Version", versionName, nameSet) + sendPart(gzipOs, boundary, "ApplicationBuildID", versionCode, nameSet) + sendPart(gzipOs, boundary, "AndroidComponentVersion", AcBuild.version, nameSet) + sendPart(gzipOs, boundary, "GleanVersion", AcBuild.gleanSdkVersion, nameSet) + sendPart(gzipOs, boundary, "ApplicationServicesVersion", AcBuild.applicationServicesVersion, nameSet) + sendPart(gzipOs, boundary, "GeckoViewVersion", version, nameSet) + sendPart(gzipOs, boundary, "BuildID", buildId, nameSet) + sendPart(gzipOs, boundary, "Vendor", vendor, nameSet) + sendPart(gzipOs, boundary, "Breadcrumbs", breadcrumbs, nameSet) + sendPart(gzipOs, boundary, "useragent_locale", Locale.getDefault().toLanguageTag(), nameSet) + sendPart(gzipOs, boundary, "DistributionID", distributionId, nameSet) + + extrasFilePath?.let { + val regex = "$FILE_REGEX$EXTRAS_FILE_EXT".toRegex() + if (regex.matchEntire(it.substringAfterLast("/")) != null) { + val extrasFile = File(it) + val extrasMap = readExtrasFromFile(extrasFile) + for (key in extrasMap.keys) { + sendPart(gzipOs, boundary, key, extrasMap[key], nameSet) + } + extrasFile.delete() + } + } + + if (throwable?.stackTrace?.isEmpty() == false) { + sendPart( + gzipOs, + boundary, + "JavaStackTrace", + getExceptionStackTrace( + throwable, + !isNativeCodeCrash && !isFatalCrash, + ), + nameSet, + ) + + sendPart(gzipOs, boundary, "JavaException", throwable.getStacktraceAsJsonString(), nameSet) + } + + miniDumpFilePath?.let { + val regex = "$FILE_REGEX$MINI_DUMP_FILE_EXT".toRegex() + if (regex.matchEntire(it.substringAfterLast("/")) != null) { + val minidumpFile = File(it) + sendFile(gzipOs, boundary, "upload_file_minidump", minidumpFile, nameSet) + minidumpFile.delete() + } + } + + when { + isNativeCodeCrash && isFatalCrash -> + sendPart(gzipOs, boundary, "CrashType", FATAL_NATIVE_CRASH_TYPE, nameSet) + isNativeCodeCrash && !isFatalCrash -> + sendPart(gzipOs, boundary, "CrashType", NON_FATAL_NATIVE_CRASH_TYPE, nameSet) + !isNativeCodeCrash && isFatalCrash -> + sendPart(gzipOs, boundary, "CrashType", UNCAUGHT_EXCEPTION_TYPE, nameSet) + !isNativeCodeCrash && !isFatalCrash -> + sendPart(gzipOs, boundary, "CrashType", CAUGHT_EXCEPTION_TYPE, nameSet) + } + + sendPackageInstallTime(gzipOs, boundary, nameSet) + sendProcessName(gzipOs, boundary, nameSet) + sendPart(gzipOs, boundary, "ReleaseChannel", releaseChannel, nameSet) + sendPart( + gzipOs, + boundary, + "StartupTime", + TimeUnit.MILLISECONDS.toSeconds(startTime).toString(), + nameSet, + ) + sendPart( + gzipOs, + boundary, + "CrashTime", + TimeUnit.MILLISECONDS.toSeconds(timestamp).toString(), + nameSet, + ) + sendPart(gzipOs, boundary, "Android_PackageName", applicationContext.packageName, nameSet) + sendPart(gzipOs, boundary, "Android_Manufacturer", Build.MANUFACTURER, nameSet) + sendPart(gzipOs, boundary, "Android_Model", Build.MODEL, nameSet) + sendPart(gzipOs, boundary, "Android_Board", Build.BOARD, nameSet) + sendPart(gzipOs, boundary, "Android_Brand", Build.BRAND, nameSet) + sendPart(gzipOs, boundary, "Android_Device", Build.DEVICE, nameSet) + sendPart(gzipOs, boundary, "Android_Display", Build.DISPLAY, nameSet) + sendPart(gzipOs, boundary, "Android_Fingerprint", Build.FINGERPRINT, nameSet) + sendPart(gzipOs, boundary, "Android_Hardware", Build.HARDWARE, nameSet) + sendPart( + gzipOs, + boundary, + "Android_Version", + "${Build.VERSION.SDK_INT} (${Build.VERSION.CODENAME})", + nameSet, + ) + + if (Build.SUPPORTED_ABIS.isNotEmpty()) { + sendPart(gzipOs, boundary, "Android_CPU_ABI", Build.SUPPORTED_ABIS[0], nameSet) + if (Build.SUPPORTED_ABIS.size >= 2) { + sendPart(gzipOs, boundary, "Android_CPU_ABI2", Build.SUPPORTED_ABIS[1], nameSet) + } + } + + gzipOs.write(("\r\n--$boundary--\r\n").toByteArray()) + gzipOs.flush() + gzipOs.close() + } + + private fun sendProcessName(os: OutputStream, boundary: String, nameSet: MutableSet<String>) { + val pid = android.os.Process.myPid() + val manager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + manager.runningAppProcesses.filter { it.pid == pid }.forEach { + sendPart(os, boundary, "Android_ProcessName", it.processName, nameSet) + return + } + } + + private fun sendPackageInstallTime(os: OutputStream, boundary: String, nameSet: MutableSet<String>) { + val packageManager = applicationContext.packageManager + try { + val packageInfo = packageManager.getPackageInfoCompat(applicationContext.packageName, 0) + sendPart( + os, + boundary, + "InstallTime", + TimeUnit.MILLISECONDS.toSeconds( + packageInfo.lastUpdateTime, + ).toString(), + nameSet, + ) + } catch (e: PackageManager.NameNotFoundException) { + logger.error("Error getting package info", e) + } + } + + private fun generateBoundary(): String { + val r0 = Random.nextInt(0, Int.MAX_VALUE) + val r1 = Random.nextInt(0, Int.MAX_VALUE) + return String.format("---------------------------%08X%08X", r0, r1) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendPart( + os: OutputStream, + boundary: String, + name: String, + data: String?, + nameSet: MutableSet<String>, + ) { + if (data == null) { + return + } + + if (nameSet.contains(name)) { + return + } else { + nameSet.add(name) + } + + try { + os.write( + ( + "--$boundary\r\nContent-Disposition: form-data; " + + "name=$name\r\n\r\n$data\r\n" + ).toByteArray(), + ) + } catch (e: IOException) { + logger.error("Exception when sending $name", e) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendFile( + os: OutputStream, + boundary: String, + name: String, + file: File, + nameSet: MutableSet<String>, + ) { + if (nameSet.contains(name)) { + return + } else { + nameSet.add(name) + } + + try { + os.write( + ( + "--${boundary}\r\n" + + "Content-Disposition: form-data; name=\"$name\"; " + + "filename=\"${file.getName()}\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n" + ).toByteArray(), + ) + } catch (e: IOException) { + logger.error("failed to write boundary", e) + return + } + + try { + val fileInputStream = FileInputStream(file).channel + fileInputStream.transferTo(0, fileInputStream.size(), Channels.newChannel(os)) + fileInputStream.close() + } catch (e: IOException) { + logger.error("failed to send file", e) + } + + try { + // Add EOL to separate from the next part + os.write("\r\n".toByteArray()) + } catch (e: IOException) { + logger.error("failed to write EOL", e) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun unescape(string: String): String { + return string.replace("\\\\\\\\", "\\").replace("\\\\n", "\n").replace("\\\\t", "\t") + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun jsonUnescape(string: String): String { + return string.replace("""\\\\""", "\\").replace("""\n""", "\n").replace("""\t""", "\t") + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + @Suppress("NestedBlockDepth") + internal fun readExtrasFromLegacyFile(file: File): HashMap<String, String> { + var fileReader: FileReader? = null + var bufReader: BufferedReader? = null + var line: String? + val map = HashMap<String, String>() + + try { + fileReader = FileReader(file) + bufReader = BufferedReader(fileReader) + line = bufReader.readLine() + while (line != null) { + val equalsPos = line.indexOf('=') + if ((equalsPos) != -1) { + val key = line.substring(0, equalsPos) + val value = unescape(line.substring(equalsPos + 1)) + if (!ignoreKeys.contains(key)) { + map[key] = value + } + } + line = bufReader.readLine() + } + } catch (e: IOException) { + logger.error("failed to convert extras to map", e) + } finally { + try { + fileReader?.close() + bufReader?.close() + } catch (e: IOException) { + // do nothing + } + } + + return map + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + @Suppress("NestedBlockDepth") + internal fun readExtrasFromFile(file: File): HashMap<String, String> { + var resultMap = HashMap<String, String>() + var notJson = false + + try { + FileReader(file).use { fileReader -> + val input = fileReader.readLines().firstOrNull() + ?: throw JSONException("failed to read json file") + + val jsonObject = JSONObject(input) + for (key in jsonObject.keys()) { + if (!key.isNullOrEmpty() && !ignoreKeys.contains(key)) { + resultMap[key] = jsonUnescape(jsonObject.getString(key)) + } + } + } + } catch (e: FileNotFoundException) { + logger.error("failed to find extra file", e) + } catch (e: IOException) { + logger.error("failed read the extra file", e) + } catch (e: JSONException) { + logger.info("extras file JSON syntax error, trying legacy format") + notJson = true + } + + if (notJson) { + resultMap = readExtrasFromLegacyFile(file) + } + + return resultMap + } + + @Suppress("TooGenericExceptionCaught") + // printStackTrace() can throw a NullPointerException exception even if throwable is not null + private fun getExceptionStackTrace(throwable: Throwable, isCaughtException: Boolean): String? { + return try { + when (isCaughtException) { + true -> "$LIB_CRASH_INFO_PREFIX ${throwable.getStacktraceAsString()}" + false -> throwable.getStacktraceAsString() + } + } catch (e: NullPointerException) { + null + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashReportService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashReportService.kt new file mode 100644 index 0000000000..5c1d8d18b7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashReportService.kt @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.notification.CrashNotification +import mozilla.components.support.base.ids.SharedIdsHelper + +private const val NOTIFICATION_TAG = "mozac.lib.crash.sendcrash" +private const val NOTIFICATION_ID = 1 + +@VisibleForTesting(otherwise = PRIVATE) +internal const val NOTIFICATION_TAG_KEY = "mozac.lib.crash.notification.tag" + +@VisibleForTesting(otherwise = PRIVATE) +internal const val NOTIFICATION_ID_KEY = "mozac.lib.crash.notification.id" + +class SendCrashReportService : Service() { + private val crashReporter: CrashReporter by lazy { CrashReporter.requireInstance } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + intent.getStringExtra(NOTIFICATION_TAG_KEY)?.apply { + NotificationManagerCompat.from(applicationContext) + .cancel(this, intent.getIntExtra(NOTIFICATION_ID_KEY, 0)) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = CrashNotification.ensureChannelExists(this) + val notification = NotificationCompat.Builder(this, channel) + .setContentTitle( + getString( + R.string.mozac_lib_send_crash_report_in_progress, + crashReporter.promptConfiguration.organizationName, + ), + ) + .setSmallIcon(R.drawable.mozac_lib_crash_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .setProgress(0, 0, true) + .build() + + val notificationId = SharedIdsHelper.getIdForTag(this, NOTIFICATION_TAG) + startForeground(notificationId, notification) + } + + NotificationManagerCompat.from(this).cancel(NOTIFICATION_TAG, NOTIFICATION_ID) + val crash = Crash.fromIntent(intent) + crashReporter.submitReport(crash) { + stopSelf() + } + + return START_NOT_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + // We don't provide binding, so return null + return null + } + + companion object { + fun createReportIntent( + context: Context, + crash: Crash, + notificationTag: String? = null, + notificationId: Int = 0, + ): Intent { + val intent = Intent(context, SendCrashReportService::class.java) + + notificationTag?.apply { + intent.putExtra(NOTIFICATION_TAG_KEY, notificationTag) + intent.putExtra(NOTIFICATION_ID_KEY, notificationId) + } + + crash.fillIn(intent) + + return intent + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashTelemetryService.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashTelemetryService.kt new file mode 100644 index 0000000000..1f312911a9 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashTelemetryService.kt @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.notification.CrashNotification +import mozilla.components.support.base.ids.SharedIdsHelper + +private const val NOTIFICATION_TAG = "mozac.lib.crash.sendtelemetry" +private const val NOTIFICATION_ID = 1 + +class SendCrashTelemetryService : Service() { + private val crashReporter: CrashReporter by lazy { CrashReporter.requireInstance } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = CrashNotification.ensureChannelExists(this) + val notification = NotificationCompat.Builder(this, channel) + .setContentTitle( + getString(R.string.mozac_lib_gathering_crash_telemetry_in_progress), + ) + .setSmallIcon(R.drawable.mozac_lib_crash_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .setProgress(0, 0, true) + .build() + + val notificationId = SharedIdsHelper.getIdForTag(this, NOTIFICATION_TAG) + startForeground(notificationId, notification) + } + + NotificationManagerCompat.from(this).cancel(NOTIFICATION_TAG, NOTIFICATION_ID) + val crash = Crash.fromIntent(intent) + crashReporter.submitCrashTelemetry(crash) { + stopSelf() + } + + return START_NOT_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + // We don't provide binding, so return null + return null + } + + companion object { + fun createReportIntent(context: Context, crash: Crash): Intent { + val intent = Intent(context, SendCrashTelemetryService::class.java) + crash.fillIn(intent) + + return intent + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/AbstractCrashListActivity.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/AbstractCrashListActivity.kt new file mode 100644 index 0000000000..eec78c334c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/AbstractCrashListActivity.kt @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R + +/** + * Activity for displaying the list of reported crashes. + */ +abstract class AbstractCrashListActivity : AppCompatActivity() { + abstract val crashReporter: CrashReporter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.mozac_lib_crash_activity_title) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .add(android.R.id.content, CrashListFragment()) + .commit() + } + } + + /** + * Gets invoked whenever the user selects a crash reporting service. + * + * @param url URL pointing to the crash report for the selected crash reporting service. + */ + abstract fun onCrashServiceSelected(url: String) +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListAdapter.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListAdapter.kt new file mode 100644 index 0000000000..e3d2b48cba --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListAdapter.kt @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.format.DateUtils +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.db.CrashWithReports +import mozilla.components.lib.crash.db.ReportEntity + +/** + * RecyclerView adapter for displaying the list of crashes. + */ +internal class CrashListAdapter( + private val crashReporter: CrashReporter, + private val onSelection: (String) -> Unit, +) : RecyclerView.Adapter<CrashViewHolder>() { + private var crashes: List<CrashWithReports> = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CrashViewHolder { + val view = LayoutInflater.from( + parent.context, + ).inflate( + R.layout.mozac_lib_crash_item_crash, + parent, + false, + ) + + return CrashViewHolder(view) + } + + override fun getItemCount(): Int { + return crashes.size + } + + override fun onBindViewHolder(holder: CrashViewHolder, position: Int) { + val crashWithReports = crashes[position] + + holder.idView.text = crashWithReports.crash.uuid + + holder.titleView.text = crashWithReports.crash.stacktrace.lines().first() + + val time = DateUtils.getRelativeDateTimeString( + holder.footerView.context, + crashWithReports.crash.createdAt, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + ) + + holder.footerView.text = SpannableStringBuilder(time).apply { + append(" - ") + + append( + holder.itemView.context.getString(R.string.mozac_lib_crash_share), + object : ClickableSpan() { + override fun onClick(widget: View) { + shareCrash(widget.context, crashWithReports) + } + }, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + if (crashWithReports.reports.isNotEmpty()) { + append(" - ") + append(crashReporter, crashWithReports.reports, onSelection) + } + } + ViewCompat.enableAccessibleClickableSpanSupport(holder.footerView) + } + + @SuppressLint("NotifyDataSetChanged") + fun updateList(list: List<CrashWithReports>) { + crashes = list + notifyDataSetChanged() + } + + private fun shareCrash(context: Context, crashWithReports: CrashWithReports) { + val text = StringBuilder() + + text.append(crashWithReports.crash.uuid) + text.appendLine() + text.append(crashWithReports.crash.stacktrace.lines().first()) + text.appendLine() + + crashWithReports.reports.forEach { report -> + val service = crashReporter.getCrashReporterServiceById(report.serviceId) + text.append(" * ") + text.append(service?.name ?: report.serviceId) + text.append(": ") + text.append(service?.createCrashReportUrl(report.reportId) ?: "<No URL>") + text.appendLine() + } + + text.append("----") + text.appendLine() + text.append(crashWithReports.crash.stacktrace) + text.appendLine() + + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + intent.putExtra(Intent.EXTRA_TEXT, text.toString()) + context.startActivity(Intent.createChooser(intent, "Crash")) + } +} + +internal class CrashViewHolder( + view: View, +) : RecyclerView.ViewHolder( + view, +) { + val titleView = view.findViewById<TextView>(R.id.mozac_lib_crash_title) + val idView = view.findViewById<TextView>(R.id.mozac_lib_crash_id) + val footerView = view.findViewById<TextView>(R.id.mozac_lib_crash_footer).apply { + movementMethod = LinkMovementMethod.getInstance() + } +} + +private fun SpannableStringBuilder.append( + crashReporter: CrashReporter, + services: List<ReportEntity>, + onSelection: (String) -> Unit, +): SpannableStringBuilder { + services.forEachIndexed { index, entity -> + val service = crashReporter.getCrashReporterServiceById(entity.serviceId) + val name = service?.name ?: entity.serviceId + val url = service?.createCrashReportUrl(entity.reportId) + + if (url != null) { + append( + name, + object : ClickableSpan() { + override fun onClick(widget: View) { + onSelection(url) + } + }, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } else { + append(name) + } + + if (index < services.lastIndex) { + append(" ") + } + } + return this +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListFragment.kt b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListFragment.kt new file mode 100644 index 0000000000..4305d2ac16 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListFragment.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.ui + +import android.database.sqlite.SQLiteBlobTooBigException +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.db.CrashDatabase + +/** + * Fragment displaying the list of crashes. + */ +internal class CrashListFragment : Fragment(R.layout.mozac_lib_crash_crashlist) { + private val database by lazy { CrashDatabase.get(requireContext()) } + private val reporter by lazy { (activity as AbstractCrashListActivity).crashReporter } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val listView: RecyclerView = view.findViewById(R.id.mozac_lib_crash_list) + listView.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.VERTICAL, + false, + ) + + val emptyView = view.findViewById<TextView>(R.id.mozac_lib_crash_empty) + + val adapter = CrashListAdapter(reporter, ::onSelection) + listView.adapter = adapter + + val dividerItemDecoration = DividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL, + ) + listView.addItemDecoration(dividerItemDecoration) + + try { + database.crashDao().getCrashesWithReports().observe( + viewLifecycleOwner, + Observer { list -> + if (list.isEmpty()) { + emptyView.visibility = View.VISIBLE + } else { + adapter.updateList(list) + } + }, + ) + } catch (e: SQLiteBlobTooBigException) { + /* recover by deleting all entries */ + database.crashDao().deleteAll() + } + } + + private fun onSelection(url: String) { + (requireActivity() as AbstractCrashListActivity).onCrashServiceSelected(url) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/drawable/mozac_lib_crash_notification.xml b/mobile/android/android-components/components/lib/crash/src/main/res/drawable/mozac_lib_crash_notification.xml new file mode 100644 index 0000000000..3eeed541e0 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/drawable/mozac_lib_crash_notification.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M3.215,18.106l6.996,-14.004c0.737,-1.475 2.841,-1.475 3.578,0l6.996,14.004A2,2 0,0 1,18.995 21L5.005,21a2,2 0,0 1,-1.79 -2.894zM12,9a1,1 0,0 1,1 1v4a1,1 0,1 1,-2 0v-4a1,1 0,0 1,1 -1zM12,18a1,1 0,1 0,0 -2,1 1,0 0,0 0,2z" + android:fillColor="#ffffffff" + android:fillType="evenOdd"/> +</vector> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashlist.xml b/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashlist.xml new file mode 100644 index 0000000000..8754a8ee11 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashlist.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/mozac_lib_crash_list" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + <TextView + android:id="@+id/mozac_lib_crash_empty" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center" + android:text="@string/mozac_lib_crash_no_crashes" + android:textAlignment="center" + android:visibility="gone" /> + +</FrameLayout>
\ No newline at end of file diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml b/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml new file mode 100644 index 0000000000..3214d191c6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml @@ -0,0 +1,81 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:padding="8dp"> + + <TextView + android:id="@+id/titleView" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:padding="10dp" + android:maxLines="3" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="@string/mozac_lib_crash_dialog_title" + style="@style/Base.DialogWindowTitle.AppCompat" /> + + <TextView + android:id="@+id/messageView" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:padding="8dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/titleView" + tools:text="As a private browser, we never save and cannot restore your last browsing session." /> + + <CheckBox + android:id="@+id/sendCheckbox" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:checked="true" + android:padding="10dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/messageView" + tools:text="@string/mozac_lib_crash_dialog_checkbox" /> + + <Button + android:id="@+id/closeButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:text="@string/mozac_lib_crash_dialog_button_close" + android:textAlignment="center" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/sendCheckbox" + style="@style/Widget.AppCompat.Button.Borderless.Colored" /> + + <Button + android:id="@+id/restartButton" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:text="@string/mozac_lib_crash_dialog_button_restart" + android:textAlignment="center" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/closeButton" + app:layout_constraintTop_toBottomOf="@+id/sendCheckbox" + style="@style/Widget.AppCompat.Button.Borderless.Colored" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_item_crash.xml b/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_item_crash.xml new file mode 100644 index 0000000000..a801b4938e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/layout/mozac_lib_crash_item_crash.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="4dp"> + + <TextView + android:id="@+id/mozac_lib_crash_id" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="2dp" + android:textSize="10sp" + tools:text="15b666ae-fc9d-41d1-a5c0-8af6961a22d4" + tools:ignore="SmallSp" /> + + <TextView + android:id="@+id/mozac_lib_crash_title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@id/mozac_lib_crash_id" + android:padding="2dp" + android:textSize="14sp" + android:textStyle="bold" + tools:text="java.lang.RuntimeException: Background crash" /> + + <TextView + android:id="@+id/mozac_lib_crash_footer" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@id/mozac_lib_crash_title" + android:padding="2dp" + android:textSize="12sp" + tools:text="12 minutes ago - Sentry Socorro"/> + +</RelativeLayout> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-am/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-am/strings.xml new file mode 100644 index 0000000000..9555f40a83 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-am/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">አዝናለሁ። %1$s ችግር ነበረበት እና ተሰናክሏል።</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">የብልሽት ሪፖርት ወደ %1$s ላክ</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ዝጋ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$sን እንደገና ያስጀምሩ</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ብልሽቶች</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">አዝናለሁ። በ%1$s ውስጥ ችግር ተፈጥሯል።</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ሪፖርት ያድርጉ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">የብልሽት ሪፖርት ወደ %1$s በመላክ ላይ</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">የብልሽት ውሂብ በመሰብሰብ ላይ</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">የብልሽት ቴሌሜትሪ መረጃን በመሰብሰብ ላይ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">የብልሽት ሪፖርቶች</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">ምንም የብልሽት ሪፖርቶች አልገቡም።</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">አጋራ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-an/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-an/strings.xml new file mode 100644 index 0000000000..baab129de7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-an/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">%1$s ha teniu un problema y ha fallau.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Ninviar reporte de fallos a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zarrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallos</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Reportar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Ninviar reporte de fallos a %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Reportes de fallos</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No s’ha ninviau garra reporte de fallos.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ar/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000000..6e5064e869 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ar/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">نأسف. واجه %1$s مشكلة وانهار.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">أرسِل تقرير الانهيار إلى %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">أغلِق</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">أعِد تشغيل %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">الانهيارات</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">أبلِغ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">يُرسل بلاغ الانهيار إلى %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">بلاغات الانهيار</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">لم تُرسل أي بلاغات بانهيار.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">شارك</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ast/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000000..d4d75fadfa --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ast/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sentímoslo, %1$s tuvo un problema ya cascó.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Unviar l\'informe del error a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zarrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reaniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Casques</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sentímoslo, prodúxose un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Unviando l\'informe del error a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recoyendo los datos del casque</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recoyendo los datos telemétricos del casque</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de casques</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nun s\'unvió nengún informe de casques.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-az/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-az/strings.xml new file mode 100644 index 0000000000..c8dfad29d5 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-az/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Üzr istəyirik. %1$s səyyahında xəta oldu və çökdü.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Çökmə hesabatını %1$s üçün göndər</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Qapat</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s tətbiqini yenidən başlat</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Qəzalar</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Xəbər ver</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Çökmə xəbəri %1$s üçün göndərilir</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Çökmə Hesabatları</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Hələlik heç bir çökmə hesabatı göndərilməyib.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Paylaş</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-azb/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-azb/strings.xml new file mode 100644 index 0000000000..f17a92f5a0 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-azb/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">باغیشلایین. %1$s موشکولونه اوزلشدی و سیندی.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">سینماق راپورتونو %1$s -یه گؤندر</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">باغلا</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s اَپینی یئنیدن باشلات</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">سینماقلار</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">باغیشلایین، %1$s اپینده موشکول قاباغا گلدی.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">راپورت</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">سینماق راپورتو %1$s-یا گؤندریلیر</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">سینماق دیتالاری یئغیلیر</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">سینماق تلهمتری دیتاسی یئغیلیر</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">سینماق راپورتلاری</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">هئچ بیر سینماق راپورتو گؤندیریلمهدی</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">پایلاش</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ban/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ban/strings.xml new file mode 100644 index 0000000000..f60b516a08 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ban/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Ampura. %1$s wenten galat lan usak.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Gatra</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Bagiang</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-be/strings.xml new file mode 100644 index 0000000000..d342233088 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-be/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Прабачце. %1$s меў цяжкасці, і адбыўся збой.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Адправіць справаздачу аб краху ў %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Закрыць</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Перазапусціць %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Крахі</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Прабачце. Узнікла праблема ў %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Паведаміць</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Дасыланне справаздачы пра крах у %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Збор дадзеных пра збой</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Збор дадзеных тэлеметрыі аб збоях</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Cправаздачы пра крахі</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ніякага паведамлення аб збоі даслана не было.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Падзяліцца</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-bg/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000000..97d7148bf7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-bg/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Извинявайте. %1$s имаше проблем и се срина.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Изпращане на докладите за срив до %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Затваряне</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Рестартиране на %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Сривове</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Извинявайте. Възникна проблем с %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Докладване</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Изпращане на докладите за срив до %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Събиране на информация за срива</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Събиране на телеметрични данни за срива</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Доклади за срив</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Няма изпратени доклади за срив.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Споделяне</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-bn/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000000..e219bb78aa --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-bn/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">দুঃখিত। %1$s এর একটি সমস্যা ছিল এবং ক্র্যাশ করেছে।</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s এ ক্র্যাশ প্রতিবেদন পাঠান</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">বন্ধ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s পুনরায় চালু করুন</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ক্র্যাশসমূহ</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">প্রতিবেদন</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s এ ক্র্যাশ প্রতিবেদন পাঠানো হচ্ছে</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ক্র্যাশ রিপোর্ট</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">কোনো ক্র্যাশের রিপোর্ট জমা দেওয়া হয়নি।</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">শেয়ার করুন</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-br/strings.xml new file mode 100644 index 0000000000..c17ba9bdbc --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-br/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Digarezit, ur gudenn a zo bet gant %1$s ha sacʼhet eo.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Kas an danevell sacʼhadenn da: %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Serriñ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Adlocʼhañ %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Sacʼhadennoù</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Hon digarezit, degouezhet ez eus bet ur fazi e %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Danevelliñ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">O kas an danevell sacʼhadenn da: %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">O tastum roadennoù ar sac’hadenn</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">O tastum roadennoù telemetrek ar sac’hadenn</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Danevelloù sacʼhadenn</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Danevell sacʼhadenn ebet bet treuzkaset.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Rannañ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-bs/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000000..acb7937611 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-bs/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Oprostite. %1$s je imao problem i srušio se.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Pošalji izvještaj o rušenju %1$s-i</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zatvori</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Restartuj %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Rušenja</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Nažalost, došlo je do problema u pozadinskom procesu %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Prijavi</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Šaljem izvještaj o rušenju %1$s-i</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Prikupljanje podataka o padu</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Prikupljanje telemetrijskih podataka o padu</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Izvještaji o rušenju</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nema poslanih izvještaja o rušenju.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Podijeli</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ca/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000..8e201ba93e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ca/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">El %1$s ha tingut un problema i ha fallat.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Envia un informe de fallada a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Tanca</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reinicia el %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallades</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">S\'ha produït un problema al %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informa</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">S’està enviant l’informe de fallada a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">S’estan recollint dades de la fallada</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">S’estan recollint dades de la fallada de telemetria</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de fallada</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No s’ha enviat cap informe de fallada.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Comparteix</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-cak/strings.xml new file mode 100644 index 0000000000..df611ad6bd --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-cak/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Kojakuyu\'. %1$s xk\'oje\' jun ruk\'ayewal chuqa\' xsach.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Titaq rutzijol sachoj chi re %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Titz\'apïx</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Titikirisäx chik %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Taq sachoj</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Takuyu\'. Xk\'oje\' jun k\'ayewal pa %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Tiya\' rutzijol</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Nitaq rutzijol sachoj chi re %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Rumolik sachoj taq tzij</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Kimolik taq rutzij rusachoj telemetriya\'</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rutzijol Taq Sachoj</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Man etaqon ta ri taq rutzijol sachoj.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Tikomonïx</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ceb/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ceb/strings.xml new file mode 100644 index 0000000000..5b6f44d93c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ceb/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sorry. Ang %1$s nagproblema ug nicrash</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">i-Padala ang crash report sa %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">i-Close</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">i-Restart %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Mga Crash</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Report</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">i-Padala ang crash report sa %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Crash Report</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Walay crash report nga ge submit.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">i-Share</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ckb/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ckb/strings.xml new file mode 100644 index 0000000000..91d84be3b9 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ckb/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ببورە. %1$s کێشەیەکی هەبوو تێکشکا.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">ڕاپۆرتی داخستنی لەناکاو بنێرە بۆ %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">داخستن</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">پێکردنەوی %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">تێکشکانەکان</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ڕاپۆرت</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">ناردنی ڕاپۆرتی تێکشکان بۆ %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ڕاپۆرتی داخستنی لەناکاو</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">هیچ ڕاپۆرتێکی داخستنی لەناکاو نەنێردراوە.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">بڵاوکردنەوە</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-co/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-co/strings.xml new file mode 100644 index 0000000000..1f2b3fa104 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-co/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Per disgrazia, %1$s hà scuntratu un prublema chì hà cagiunatu un accidente.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Mandà un raportu d’accidente à %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Chjode</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Rilancià %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Accidenti</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Per disgrazia, un prublema hè accadutu in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Signalà</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Inviu di u raportu d’accidente à %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Culletta di i dati di l’accidente</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Culletta di i dati di telemetria di l’accidente</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Raporti d’accidente</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Alcunu raportu d’accidente ùn hè statu mandatu.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Sparte</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-cs/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000000..95e35643ac --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-cs/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Promiňte. V aplikaci %1$s nastal problém a spadla.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Poslat hlášení o pádu společnosti %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zavřít</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Restartovat aplikaci %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Pády</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">V aplikaci %1$s došlo k chybě.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Nahlásit</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Odesílání hlášení o pádu společnosti %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Shromažďování dat o pádu</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Shromažďování telemetrických dat o pádu</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Hlášení pádů</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Žádná hlášení nebyla odeslána.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Sdílet</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-cy/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000000..ee8b79fc90 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-cy/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Ymddiheuriadau. Cafodd %1$s anhawster a chwalu.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Anfon adroddiad chwalu at %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Cau</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Ailgychwyn %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Chwalfeydd</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Ymddiheuriadau. Digwyddodd anhawster yn %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Adrodd</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Anfon adroddiad chwalu at %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Casglu data chwaliadau</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Casglu data telemetreg chwaliadau</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Adroddiadau Chwalu</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Does dim adroddiadau chwalu wedi eu cyflwyno.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Rhannu</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-da/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..e4d0ef279e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-da/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Beklager, %1$s fik et problem og gik ned.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Send fejlrapport til %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Luk</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Genstart %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Nedbrud</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Beklager, men der opstod et problem i %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Rapporter</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Sender fejlrapport til %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Indsamler data om nedbrud</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Indsamler telemetri-data om nedbrud</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Fejlrapporter</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Du har ikke indsendt nogen fejlrapporter.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Del</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-de/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..54e7c24653 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-de/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Es tut uns leid. %1$s hatte ein Problem und ist abgestürzt.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Absturzbericht an %1$s senden</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Schließen</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s neu starten</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Abstürze</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Entschuldigung. Bei %1$s ist ein Problem aufgetreten.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Melden</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Absturzbericht wird an %1$s gesendet</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Absturzdaten werden erfasst</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Telemetriedaten zum Absturz werden gesammelt</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Absturzberichte</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Es wurden noch keine Absturzberichte versendet.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Teilen</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-dsb/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-dsb/strings.xml new file mode 100644 index 0000000000..ba05b962f6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-dsb/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Bóžko %1$s jo měł problem a jo se wowalił.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s rozpšawu wowalenja pósłaś</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zacyniś</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s znowego startowaś</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Wówalenja</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Wódajśo. Problem jo nastał w %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">K wěsći daś</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Wowaleńska rozpšawa se %1$s sćelo</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Daty wowalenja se gromaźe</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Telemetrijowe daty wowalenjow se gromaźe</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rozpšawy wowalenjow</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Rozpšawy wó wowalenjach njejsu se rozpósłali.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Źěliś</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-el/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-el/strings.xml new file mode 100644 index 0000000000..452732b283 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-el/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Λυπούμαστε. Το %1$s αντιμετώπισε πρόβλημα και κατέρρευσε.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Αποστολή αναφοράς κατάρρευσης στη %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Κλείσιμο</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Επανεκκίνηση του %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Καταρρεύσεις</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Δυστυχώς, προέκυψε ένα πρόβλημα στο %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Αναφορά</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Αποστολή αναφοράς κατάρρευσης στη %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Συλλογή δεδομένων κατάρρευσης</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Συλλογή δεδομένων τηλεμετρίας κατάρρευσης</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Αναφορές κατάρρευσης</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Δεν έχουν υποβληθεί αναφορές κατάρρευσης.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Κοινή χρήση</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-en-rCA/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-en-rCA/strings.xml new file mode 100644 index 0000000000..38b0b2e647 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-en-rCA/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sorry. %1$s had a problem and crashed.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Send crash report to %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Close</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Restart %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Crashes</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sorry. A problem occurred in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Report</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Sending crash report to %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Gathering crash data</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Gathering crash telemetry data</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Crash Reports</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No crash reports have been submitted.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Share</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-en-rGB/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000000..38b0b2e647 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sorry. %1$s had a problem and crashed.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Send crash report to %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Close</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Restart %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Crashes</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sorry. A problem occurred in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Report</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Sending crash report to %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Gathering crash data</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Gathering crash telemetry data</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Crash Reports</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No crash reports have been submitted.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Share</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000000..5c7bc08637 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-eo/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Bedaŭrinde %1$s alfrontis problemon kaj paneis.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Sendi raporton pri paneo al %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Fermi</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Restartigi %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Paneoj</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Bedaŭrinde problemo okazis en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Raporto</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Raporto pri paneo sendata al %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Kolektado de datumoj pri paneo</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Telemezuraj datumoj akirataj</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Raportoj pri paneo</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Neniu raporto pri paneo estis sendita.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Dividi</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rAR/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rAR/strings.xml new file mode 100644 index 0000000000..05bfa9734c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rAR/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Disculpá. %1$s tuvo un problema y falló.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar informe del fallo a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Cerrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallos</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Disculpá. Ocurrió un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviando informe de fallo a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recopilando datos de la colgada</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recopilación de datos de telemetría de fallos</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de fallos</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No se enviaron informes de fallos.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rCL/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rCL/strings.xml new file mode 100644 index 0000000000..b4cd64fb0d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rCL/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Lo sentimos. %1$s tuvo un problema y se cayó.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar reporte de fallos a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Cerrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallos</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Lo sentimos. Ocurrió un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Reportar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviando reporte de fallos a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recopilando datos de fallos</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recopilación de datos de telemetría de fallos</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Reportes de fallos</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No se ha enviado ningún reporte de fallos.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rES/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..c367b36eb6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Lo sentimos. Hubo un problema con %1$s y se ha cerrado.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar informe de fallos a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Cerrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallos</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Lo sentimos. Ha ocurrido un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informe</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviando informe de fallo a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recopilando datos de fallos</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recopilando datos de telemetría de fallos</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de fallos</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No se ha enviado ningún informe de fallos.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rMX/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rMX/strings.xml new file mode 100644 index 0000000000..f68b71a3ef --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-es-rMX/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Lo sentimos. Hubo un problema con %1$s y se cerró.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar informe de fallos a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Cerrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallos</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Lo siento. Ha ocurrido un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviando informe de fallo a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recopilación de datos de errores</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recopilación de datos de telemetría de fallos</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Reportes de fallo</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No se han enviado reportes de fallo.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-es/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-es/strings.xml new file mode 100644 index 0000000000..c367b36eb6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-es/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Lo sentimos. Hubo un problema con %1$s y se ha cerrado.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar informe de fallos a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Cerrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Fallos</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Lo sentimos. Ha ocurrido un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informe</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviando informe de fallo a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recopilando datos de fallos</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recopilando datos de telemetría de fallos</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de fallos</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No se ha enviado ningún informe de fallos.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-et/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-et/strings.xml new file mode 100644 index 0000000000..53a9e483d6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-et/strings.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Vabandust. %1$sil esines probleem ja see jooksis kokku.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$sle saadetakse vearaport</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Sulge</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Taaskäivita %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Kokkujooksmised</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Vabandust. %1$s esines probleem.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Raporteeri</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Vearaporti saatmine %1$sle</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Vearaportid</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ühtegi vearaportit pole saadetud.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Jaga</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000000..de1d055c87 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-eu/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Barkatu. %1$s(e)k arazo bat izan du eta huts egin du.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Bidali hutsegite-txostena %1$s(e)ra</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Itxi</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Berrabiarazi %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Hutsegiteak</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Barkatu. Arazo bat gertatu da %1$s(e)n.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Jakinarazi</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Hutsegite-txostena %1$s(e)ra bidaltzen</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Hutsegitearen datuak biltzen</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Hutsegitearen datu telemetrikoak biltzen</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Hutsegite-txostenak</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ez da bidali hutsegite-txostenik.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Partekatu</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-fa/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000000..bc98d38b0f --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-fa/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">متأسفیم؛ %1$s مشکلی داشته و فروپاشیده است.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">ارسال گزارش فروپاشیها به %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">بستن</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">شروع دوبارهٔ %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">فروپاشیها</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">متأسفیم؛ مشکلی در %1$s رخ داد.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">گزارش</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">در حال ارسال گزارش فروپاشی به %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">جمعآوری دادههای فروپاشی</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">جمعآوری دادههای دورسنجی فروپاشی</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">گزارشهای فروپاشی</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">هیچ گزارش فروپاشیای ارسال نشده است.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">همرسانی</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ff/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ff/strings.xml new file mode 100644 index 0000000000..0b13934ffe --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ff/strings.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Njaafoɗaa. %1$s dañiino caɗeele etee hookii.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Neldu jaŋte kooki e %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Uddu</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Fuɗɗito %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Kooki</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Jaŋtol</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Neldugol jaŋte kooki e %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Jaŋtol Kooke</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Alaa jaŋte hookre neldaa.</string> + + </resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-fi/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..6a720554ce --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-fi/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Valitettavasti %1$s kohtasi ongelman ja kaatui.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Lähetä kaatumisraportti %1$slle</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Sulje</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Käynnistä %1$s uudelleen</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Kaatumiset</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">%1$sissa ilmeni ongelma.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Lähetä raportti</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Lähetetään kaatumisraportti %1$slle</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Kerätään kaatumistietoja</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Kerätään kaatumistelemetriatietoja</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Kaatumisraportit</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Kaatumisraportteja ei ole lähetetty.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Jaa</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-fr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..87ebf25320 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-fr/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Désolé, %1$s a rencontré un problème et a planté.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Envoyer le rapport de plantage à %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Fermer</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Redémarrer %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Plantages</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Désolé. Un problème est survenu dans %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Signaler</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Envoi du rapport de plantage à %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Collecte des données de plantage</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Collecte des données de télémétrie du plantage</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rapports de plantage</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Aucun rapport de plantage n’a été envoyé.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Partager</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-fur/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-fur/strings.xml new file mode 100644 index 0000000000..ac731a80af --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-fur/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Nus displâs. %1$s al à vût un probleme e al è colassât.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Invie la segnalazion di colàs a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Siere</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Torne invie %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Colàs</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Nus displâs. Al è capitât un probleme in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Segnale</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Daûr a inviâ la segnalazion di colàs a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Daûr a racuei i dâts sui colàs</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Daûr a racuei i dâts di telemetrie dai colàs</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Segnalazions di colàs</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No je stade mandade nissune segnalazion di colàs.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Condivît</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-fy-rNL/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-fy-rNL/strings.xml new file mode 100644 index 0000000000..5d6dfb1db1 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-fy-rNL/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sorry, %1$s hie in probleem en is ferûngelokke.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Ungelokrapport nei %1$s ferstjoere</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Slute</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s opnij starte</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Ungelokken</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sorry. Der is in probleem bard yn %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Melde</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Ungelokrapport nei %1$s ferstjoere</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Ungelokgegevens sammelje</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Gegevens oer ûngelok-telemetry sammelje</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Ungelokrapporten</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Der binne gjin ûngelokrapporten ynstjoerd.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Diele</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ga-rIE/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ga-rIE/strings.xml new file mode 100644 index 0000000000..0451788058 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ga-rIE/strings.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Tá %1$s tar éis tuairteála.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Seol tuairisc tuairteála chuig %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Dún</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Atosaigh %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Tuairteanna</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Déan Tuairisc</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Tuairisc tuairteála á seoladh chuig %1$s</string> + + </resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-gd/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000000..cb41a84614 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-gd/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Tha sinn duilich ach dh’èirich duilgheadas dha %1$s ’s thuislich e.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Cuir aithisg tuislidh gu %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Dùin</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Ath-thòisich %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Tuislidhean</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Tha sinn duilich ach dh’èirich duilgheadas ann an %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Dèan aithris</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">A’ cur aithisg an tuislidh gu %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">A’ cruinneachadh an dàta mun tuisleadh</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">A’ cruinneachadh dàta telemeatraidh mun tuisleadh</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Aithisgean tuislidh</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Cha deach aithisg air tuisleadh a chur.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Co-roinn</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-gl/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000000..46b18fd78b --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-gl/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sentímolo. %1$s tivo un problema e fallou.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar informe de fallo a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Pechar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Quebras</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sentímolo. Ocorreu un problema en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Informar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviar informe de quebra a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Recompilando datos de quebras</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recompilando datos de telemetría de quebras</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de quebra</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Non se enviou ningún informe de quebra.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-gn/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-gn/strings.xml new file mode 100644 index 0000000000..d1e7c70dc6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-gn/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Rombyasy. %1$s iñapañuãi ha oñemboty.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Emomarandu jejavygua %1$s-pe</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Mboty</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Emoñepyrũjey %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Jejavy</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Ambyasy. Oiko apañuãi %1$s-pe.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Momarandu</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Emomarandu jejavygua %1$s-pe</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Ombyatyhína mba’ekuaarã javypyre</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Telemetría marandu ñembyaty rehegua</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Marandu Javygua</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ndojeguerahaukái jejavy momarandu.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Moherakuã</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-gu-rIN/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-gu-rIN/strings.xml new file mode 100644 index 0000000000..9bff714e6c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-gu-rIN/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">માફ કરશો. %1$s ને કોઈ સમસ્યા હતી અને ક્રેશ થયું.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$sને ક્રેશ રિપોર્ટ મોકલો</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">બંધ કરો</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ફરીથી શરૂ કરો</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ક્રેશ</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">અહેવાલ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s પર ક્રેશ રિપોર્ટ મોકલી રહ્યાં છીએ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ભંગાણ અહેવાલો</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">કોઈ ભંગાણ અહેવાલો જમા થયેલ નથી.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">શેર કરો</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-hi-rIN/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-hi-rIN/strings.xml new file mode 100644 index 0000000000..6e7bd2fbb6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-hi-rIN/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">क्षमा करें, %1$s में एक त्रुटि उत्पन्न हुई और क्रैश हो गया।</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s को क्रैश की रिपोर्ट भेजें</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">बंद करें</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s को पुनः प्रारंभ करें</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">क्रैश</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">रिपोर्ट</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s को क्रैश रिपोर्ट भेजा जा रहा है</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">क्रैश रिपोर्ट</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">कोई क्रैश रिपोर्ट जमा नहीं किया गया है।</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">साझा करें</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-hil/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-hil/strings.xml new file mode 100644 index 0000000000..749f53f61e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-hil/strings.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Isarado</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Crashes</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Ibalita</string> + + </resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-hr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000000..f82ee68fca --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-hr/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Oprosti, %1$s je imao problem i urušio se.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Pošalji izvještaj o rušenju na %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zatvori</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Ponovo pokreni %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Rušenja</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Oprostite. Došlo je do problema u %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Prijavi</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Šalje se izvještaj o rušenju na %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Prikupljanje podataka o padu</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Prikupljanje telemetrijskih podataka o padu</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Izvještaji rušenja</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nema poslanih izvještaja rušenja.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Podijeli</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-hsb/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-hsb/strings.xml new file mode 100644 index 0000000000..d838bfa9e4 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-hsb/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Bohužel je %1$s problem měł a spadnył.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s spadowu rozprawu pósłać</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Začinić</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s znowa startować</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Spady</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Wodajće. Problem je w %1$s nastał.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Zdźělić</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Spadowa rozprawa so %1$s sćele</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Daty spada so hromadźa</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Telemetrijowe daty spadow so hromadźa</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rozprawy wo spadach</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Rozprawy wo spadach njejsu so rozpósłali.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Dźělić</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-hu/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..e5529da97b --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-hu/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sajnáljuk. A %1$s problémába ütközött és összeomlott.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Összeomlás-jelentés elküldése a %1$s számára</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Bezárás</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s újraindítása</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Összeomlások</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Elnézést. Probléma történt itt: %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Jelentés</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Összeomlás-jelentés elküldése a %1$s számára</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Összeomlási adatok gyűjtése</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Összeomlási telemetriai adatok gyűjtése</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Összeomlásjelentések</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nem volt még beküldve jelentés.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Megosztás</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-hy-rAM/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-hy-rAM/strings.xml new file mode 100644 index 0000000000..b30eed3fce --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-hy-rAM/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Ներողություն. %1$s-ը խնդիր ունեցավ և խափանվեց:</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Ուղարկել վթարի զեկույցը %1$sին</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Փակել</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Վերամեկնարկել %1$s-ը</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Վթարներ</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Ներողություն. %1$s-ում խնդիր է առաջացել:</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Զեկույց</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Ուղարկել վթարի զեկույցը %1$s-ին</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Վթարի տվյալների հավաքում</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Վթարի հեռաչափության տվյալների հավաքում</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Վթարի զեկույց</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Վթարային ոչ մի հաղորդագրություն չի ուղարկվել:</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Տարածել</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ia/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ia/strings.xml new file mode 100644 index 0000000000..841dcecd25 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ia/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Nos regretta. %1$s habeva un problema e collabeva.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Inviar reporto de crash a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Clauder</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reinitiar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Crashes</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Desolate. Un problema occurreva in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Reportar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Invio de reporto de crash a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Collection datos de crash</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Colligente datos de telemetria de crash</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Reportos de collapso</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nulle reportos de collapso esseva submittite.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartir</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-in/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..24f7d67e80 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-in/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Maaf. %1$s mengalami masalah dan mogok.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Kirim laporan mogok ke %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Tutup</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Mulai Ulang %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Mogok</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Maaf. Terjadi masalah pada %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Laporkan</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Kirim laporan mogok ke %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Mengumpulkan data mogok</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Mengumpulkan data telemetri mogok</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Laporan Kerusakan</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Tidak ada laporan kerusakan yang pernah dikirim.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Bagikan</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-is/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-is/strings.xml new file mode 100644 index 0000000000..e13fe8bef7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-is/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Því miður þá lenti %1$s í erfiðleikum og lokaðist.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Senda hrunskýrslu til %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Loka</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Endurræsa %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Hrun</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Því miður. Vandamál kom upp í %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Skýrsla</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Senda hrunaskýrslu til %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Safna hrungögnum</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Safna fjarmælingargögnum um hrun</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Hrunskýrslur</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Engar hrunaskýrslur hafa verið sendar.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Deila</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-it/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..3a1cb7deef --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-it/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Si è verificato un problema in %1$s che ha provocato un arresto anomalo.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Invia la segnalazione di arresto anomalo a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Chiudi</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Riavvia %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Arresti anomali</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Siamo spiacenti. Si è verificato un problema in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Segnala</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Invio in corso della segnalazione di arresto anomalo a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Raccolta dei dati sugli arresti anomali</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Raccolta dei dati di telemetria relativi agli arresti anomali</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Segnalazioni di arresto anomalo</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Non è stata inviata alcuna segnalazione.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Condividi</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-iw/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000000..36da8bccdb --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-iw/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">קרתה תקלה עם %1$s שהובילה לקריסה. עמך הסליחה.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">שליחת דיווח קריסה אל %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">סגירה</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">הפעלת %1$s מחדש</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">קריסות</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">מצטערים. אירעה שגיאה ב־%1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">דיווח</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">דיווח קריסה נשלח אל %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">בתהליך איסוף נתוני קריסה</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">בתהליך איסוף נתוני קריסה</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">דיווחי קריסה</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">לא נשלחו דיווחי קריסה.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">שיתוף</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ja/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..94f58ef947 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ja/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">申し訳ありません。%1$s に問題がありクラッシュしました。</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">クラッシュレポートを %1$s へ送信する</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">閉じる</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s を再起動</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">クラッシュ</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">申し訳ありません。%1$s で問題が発生しました。</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">レポート</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">クラッシュレポートを %1$s へ送信しています</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">クラッシュデータを収集しています</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">クラッシュのテレメトリーデータを収集しています</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">クラッシュレポート</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">送信したクラッシュレポートはありません。</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">共有</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ka/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000000..ca895abf3d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ka/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ვწუხვართ. %1$s გაუმართაობის გამო გაითიშა.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">გათიშვის მოხსენების გადაგზავნა %1$s-სთვის</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">დახურვა</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">ხელახლა გაეშვას %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">უეცარი გათიშვები</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">სამწუხაროდ, ხარვეზს წააწყდა %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">მოხსენება</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">გათიშვის მოხსენება ეგზავნება %1$s-ს</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">გროვდება გათიშვის მონაცემები</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">აღირიცხება უეცარი გათიშვის მონაცემები</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">გათიშვების მოხსენებები</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">გათიშვების მოხსენებები არ გაგზავნილა.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">გაზიარება</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-kaa/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-kaa/strings.xml new file mode 100644 index 0000000000..3a741dc889 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-kaa/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Keshiresiz. %1$s da mashqala sebepli nasazlıq júz berdi.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Nasazlıq haqqındaǵı maģlıwmattı %1$s ǵa jiberiw</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Jabıw</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s qayta baslaw</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Nasazlıqlar</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Keshiresiz. %1$s da nasazlıq júz berdi.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Xabar berıw</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Nasazlıq esabattı %1$s ǵa jiberilip atır</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Nasazlıq haqqındaǵı maģlıwmatlardı toplaw</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Telemetriyanıń nasazlıq haqqındaǵı maǵlıwmatların jıynaw</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Nasazlıqlar haqqında esabatlar</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Hesh qanday nasazlıq haqqında esabat jiberilmegen</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Bólisiw</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000000..093ac3608f --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-kab/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Nesḥasef. %1$s isεa ugur sakin yeɣli.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Azen aneqqis n uɣelluy i %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Mdel</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Ales asenker n %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Aɣelluy</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Nesḥassef. Yeḍra-d wugur deg %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Aneqqis</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Tuzna n uneqqis n uɣelluy i %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Alqaḍ n yisefka yerrẓen</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Alqaḍ n yisefka n tilisɣelt yerrẓen</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Ineqqisen n uɣelluy</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ula d yiwen n uneqqis n uɣelluy ur yettwazen.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Bḍu</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-kk/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000000..631c4cda4e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-kk/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Кешіріңіз. %1$s мәселеге тап болды және құлады.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s адресіне құлау жөнінде хабарламаны жіберу</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Жабу</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s қайта қосу</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Құлаулар</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Кешіріңіз. %1$s ішінде қате орын алды.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Хабарлау</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s адресіне құлау жөнінде хабарламаны жіберу</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Құлау деректерін жинау</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Құлау телеметрия деректерін жинау</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Құлау туралы хабарлар</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Құлау туралы ешбір хабар жіберілмеген.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Бөлісу</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-kmr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-kmr/strings.xml new file mode 100644 index 0000000000..1ac1afcd07 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-kmr/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Bibore. %1$s li rastî pirsgirêkekê hat û têk çû.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Rapora têkçûnê ji %1$s’ê re bişîne</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Bigire</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s’ê dîsa bide destpêkirin</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Têkçûn</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Bibore. Di %1$s`ê de problemek derket.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Rapor bike</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Rapora têkçûnê ji %1$s’ê re tê şandin</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Agahiyên têkçûnê tên berhevkirin</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Agahiyên têkçûnê tên berhevkirin</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Raporên Têkçûnê</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Heta niha ti raporên têkçûnê nehatine şandin.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Parve bike</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-kn/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-kn/strings.xml new file mode 100644 index 0000000000..ec0ced1ab7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-kn/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ಕ್ಷಮಿಸಿ. %1$s ಸಮಸ್ಯೆ ಮತ್ತು ಕ್ರ್ಯಾಶ್ ಆಗಿದೆ.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">ಕ್ರ್ಯಾಶ್ ವರದಿಯನ್ನು %1$s ಗೆ ಕಳುಹಿಸಿ</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ಮುಚ್ಚು</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ಅನ್ನು ಮರು ಆರಂಭಿಸು</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ಕುಸಿತಗಳು</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ವರದಿ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">ಕ್ರ್ಯಾಶ್ ವರದಿಯನ್ನು %1$s ಗೆ ಕಳುಹಿಸಿ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ಕ್ರಿಯಾವೈಫಲ್ಯ ವರದಿಗಳು</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">ಯಾವುದೆ ಕುಸಿತ ವರದಿಗಳನ್ನು ಸಲ್ಲಿಸಲಾಗಿಲ್ಲ.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">ಹಂಚು</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ko/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..c3b3ce333e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ko/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">죄송합니다. %1$s에 문제가 발생하여 충돌했습니다.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s에 충돌 보고서 보내기</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">닫기</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s 다시 시작</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">충돌</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">죄송합니다. %1$s에서 문제가 발생했습니다.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">보고하기</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s에 충돌 보고서 보내기</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">충돌 데이터 수집 중</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">충돌 원격 분석 데이터 수집 중</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">충돌 보고서</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">전송한 충돌 보고서가 없습니다.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">공유</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-lij/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-lij/strings.xml new file mode 100644 index 0000000000..13283a6e5d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-lij/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Ne spiaxe. %1$s o l\'à avuto \'n problema e o s\'é ciantou.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Manda a segnalaçion do cianto a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Særa</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Arvi torna %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Cianti</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Denunçia</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Mando o report do cianto a %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Segnalaçion di Cianti</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nisciun report mandou.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Condividdi</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-lo/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-lo/strings.xml new file mode 100644 index 0000000000..7be9bbe480 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-lo/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ຂໍອະໄພ. %1$s ມີປັນຫາ ແລະ ລົ້ມເຫລວ.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">ສົ່ງລາຍງານການຂັດຂ້ອງໄປຫາ %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ປິດ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">ລີສຕາດ %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ການຂັດຂ້ອງ</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">ຂໍອະໄພ. ໄດ້ມີບັນຫາເກີດຂື້ນໃນ %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ລາຍງານ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">ກຳລັງສົ່ງລາຍງານການຂັດຂ້ອງໄປຫາ %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">ກຳລັງເກັບກຳຂໍ້ມູນມທີມີບັນຫາ</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">ກຳລັງເກັບກຳຂໍ້ມູນ telemetry crash</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ລາຍງານຂໍ້ຂັດຂ້ອງ</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">ຍັງບໍ່ເຄີຍສົ່ງລາຍງານຂໍ້ຜິດພາດຈັກເທື່ອ.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">ແບ່ງປັນ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-lt/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000000..1915fd6bd8 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-lt/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Atsiprašome. „%1$s“ susidūrė su problema ir užstrigo.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Pranešti apie strigtį „%1$s“</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Užverti</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Paleisti „%1$s“ iš naujo</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Strigtys</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Atsiprašome. „%1$s“ susidūrė su problema.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Siųsti pranešimą</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Strigties pranešimas siunčiamas į „%1$s“</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Renkami strigčių duomenys</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Renkami strigčių telemetrijos duomenys</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Strigčių pranešimai</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Išsiųstų strigčių pranešimų nėra.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Dalintis</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ml/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000000..1b9f328dbc --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ml/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ക്ഷമിക്കണം. %1$s ന് ഒരു പ്രശ്നമുണ്ടായി, തകർന്നു.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">തകരാർ റിപ്പോർട്ട് %1$s ലേക്ക് അയയ്ക്കുക</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">അടയ്ക്കുക</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s പുനരാരംഭിക്കുക</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">തകരാറുകള്</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">റിപ്പോര്ട്ട്</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">തകരാർ വിവരണം %1$s ലേക്ക് അയയ്ക്കുന്നു</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">തകരാർ വിവരണം</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">തകരാർ വിവരങ്ങൾ സമര്പ്പിച്ചിട്ടില്ല.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">പങ്കിടുക</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-mr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-mr/strings.xml new file mode 100644 index 0000000000..33ef33ca32 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-mr/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">क्षमस्व. %1$s मध्ये समस्या आली आणि बंद पडला.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">समस्येचा अहवाल %1$s ला पाठवा</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">बंद</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s पुन्हा सुरू करा</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">क्रॅश</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">अहवाल द्या</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">समस्येचा चा अहवाल %1$s ला पाठवत आहे</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">क्रॅश अहवाल</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">कुठलेही क्रॅश अहवाल दाखल केले गेले नाहीत.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">सामायिक करा</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-my/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-my/strings.xml new file mode 100644 index 0000000000..d60c7f80cb --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-my/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ဝမ်းနည်းပါတယ်။ %1$s တွင် ပြဿနာတစ်ခု ပေါ်ခဲ့သဖြင့် ရပ်ဆိုင်းသွားသည်။</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s သို့ ပျက်စီးမှုအစီရင်ခံစာပို့ပါ</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ပိတ်ပါ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ကိုပြန်စပါ</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ပျက်စီးမှုများ</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">အစီရင်ခံပါ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s သို့ ပျက်စီးမှုအစီရင်ခံစာပို့ပါ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ပျက်ဆီးမှု အစီရင်ခံစာများ</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">မည်သည့် ပျက်စီးမှု အစီရင်ခံစာ မျှ မတင်သွင်းထားပါ။</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">မျှဝေ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..12f4477c51 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Beklager. %1$s fikk problem og krasjet.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Send krasjrapport til %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Lukk</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Start %1$s på nytt</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Krasj</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Beklager. Det oppsto et problem i %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Rapporter</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Sender krasjrapport til %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Samler inn krasjdata</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Samler krasj-telemetridata</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Krasjrapporter</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ingen krasjrapporter er sendt inn.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Del</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ne-rNP/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ne-rNP/strings.xml new file mode 100644 index 0000000000..2ee6319010 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ne-rNP/strings.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">माफ गर्नुहोस्। %1$s मा समस्या थियो र क्र्यास भएको थियो।</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s लाई क्र्यास प्रतिबेदन पठाउनुहोस्</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">बन्द गर्नुहोस्</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s पुनः सुचारु गर्नुहोस्</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">क्र्यासहरु</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">माफ गर्नुहोस्। %1$s मा एउटा समस्या आयो।</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">रिपोर्ट</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s लाई क्र्यास प्रतिबेदन पठाइँदै</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">क्र्यास प्रतिवेदनहरु</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">कुनै पनि क्रा्यास प्रतिबेदनहरु पेश गरिएको छैन।</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">सेयर</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-nl/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..f0ed896cc7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-nl/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sorry. %1$s had een probleem en is gecrasht.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Crashrapport naar %1$s verzenden</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Sluiten</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s herstarten</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Crashes</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sorry. Er is een probleem opgetreden in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Melden</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Crashrapport naar %1$s verzenden</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Crashgegevens verzamelen</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Gegevens over crash-telemetrie verzamelen</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Crashrapporten</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Er zijn geen crashrapporten verzonden.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Delen</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-nn-rNO/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-nn-rNO/strings.xml new file mode 100644 index 0000000000..7406570a66 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-nn-rNO/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Beklagar. %1$s fekk problem og krasja</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Send krasjrapport til %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Lat att</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Start %1$s på nytt</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Krasj</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Beklagar. Det oppsto eit problem i %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Rapporter</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Sender krasjrapport til %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Samlar inn krasjdata</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Samlar krasj-telemetridata</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Krasjrapportar</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ingen krasjrapportar er sende inn.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Del</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-oc/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-oc/strings.xml new file mode 100644 index 0000000000..58f48f7a0c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-oc/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">O planhèm. %1$s a rescontrat un problèma e a quitat de foncionar.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar un senhalament de bug a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Tampar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reaviar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Plantatges</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Desolat. Un problèma s’es produch dins %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Senhalar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Mandadís del rapòrt de plantatge a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Amassada de las donadas de plantatge</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Amassada de las donadas de telemetria de plantatge</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rapòrts de plantatge</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Cap de rapòrt de plantatge es pas estat mandat.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Partejar</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-pa-rIN/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-pa-rIN/strings.xml new file mode 100644 index 0000000000..39b371daa1 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-pa-rIN/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ਅਫ਼ਸੋਸ ਹੈ। %1$s ਨੂੰ ਸਮੱਸਿਆ ਆਈ ਤੇ ਕਰੈਸ਼ ਹੋ ਗਿਆ ਹੈ।</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">ਕਰੈਸ਼ ਰਿਪੋਰਟ %1$s ਨੂੰ ਭੇਜੋ</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ਬੰਦ ਕਰੋ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ਮੁੜ-ਚਾਲੂ ਕਰੋ</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ਕਰੈਸ਼</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">ਅਫ਼ਸੋਸ ਹੈ। %1$s ਵਿੱਚ ਸਮੱਸਿਆ ਆਈ ਹੈ।</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ਰਿਪੋਰਟ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">ਕਰੈਸ਼ ਰਿਪੋਰਟ %1$s ਨੂੰ ਭੇਜੀ ਜਾ ਰਹੀ ਹੈ</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">ਕਰੈਸ਼ ਸੰਬੰਧੀ ਡਾਟੇ ਨੂੰ ਇਕੱਤਰ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">ਕਰੈਸ਼ ਟੈਲੀਮੈਂਟਰੀ ਡਾਟਾ ਇਕੱਤਰ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ਕਰੈਸ਼ ਰਿਪੋਰਟਾਂ</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">ਕੋਈ ਕਰੈਸ਼ ਰਿਪੋਰਟ ਨਹੀਂ ਦਿੱਤੀ ਗਈ</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">ਸਾਂਝਾ ਕਰੋ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-pa-rPK/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-pa-rPK/strings.xml new file mode 100644 index 0000000000..f31d7f3974 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-pa-rPK/strings.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">بند کرو</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ریپورٹ کرو</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">سانجھا کرو</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-pl/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..bd70ea643b --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-pl/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">%1$s uległ awarii.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Zgłoś awarię organizacji %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zamknij</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Uruchom program %1$s ponownie</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Awarie</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">%1$s uległ awarii</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Zgłoś</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Zgłaszanie awarii organizacji %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Zbieranie danych o awarii</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Zbieranie danych telemetrycznych awarii</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Zgłoszenia awarii</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nie przesłano żadnych zgłoszeń awarii.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Udostępnij</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-pt-rBR/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..9da369c166 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Desculpe, o %1$s teve um problema e travou.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar relatório de travamento para a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Fechar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar o %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Travamentos</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Desculpe, houve um problema no %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Relatar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Enviando relatório de travamento para a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Coletando dados de falha</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recolhendo dados de telemetria de travamentos</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Relatórios de travamento</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nenhum relatório de travamento foi enviado.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Compartilhar</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-pt-rPT/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..4d52fb69b6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Desculpe. %1$s teve um problema e falhou.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Enviar relatório de falha para %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Fechar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reiniciar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Falhas</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Pedimos desculpa. Ocorreu um problema no %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Reportar</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">A enviar relatório de falha para %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">A reunir dados de falha</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Recolha de dados de telemetria de falhas</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Relatórios de falhas</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Não foram submetidos relatórios de falhas.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Partilhar</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-rm/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-rm/strings.xml new file mode 100644 index 0000000000..0787a90e99 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-rm/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Perstgisa. %1$s ha gì in problem ed è collabà.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Trametter in rapport da collaps a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Serrar</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reaviar %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Collaps</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Perstgisa. Igl ha dà in problem en %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Rapport</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Spediziun dal rapport da collaps a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Datas da collaps vegnan rimnadas</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Rimnada da datas da telemetria davart collaps</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rapports da collaps</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Anc nagins rapports da collaps tramess.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Cundivider</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ro/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000000..0d67a3b1ba --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ro/strings.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Ne pare rău. %1$s a avut o problemă și s-a închis neașteptat.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Trimite raportul de defecțiune la %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Închide</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Repornește %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Defecțiuni</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Raportează</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Se trimite raportul de defecțiune la %1$s</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Rapoarte de defecțiuni</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nu au fost trimise rapoarte de defecțiuni.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Partajează</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ru/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..c8629a9534 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ru/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Извините. В %1$s возникла проблема и произошёл сбой.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Отправлять сообщения о падениях в %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Закрыть</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Перезапустить %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Падения</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Извините. В %1$s возникла проблема.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Сообщить</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Сообщение о падении отправляется в %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Сбор данных о падении</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Сбор данных телеметрии о падениях</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Сообщения о падениях</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ни одного сообщения о падении отправлено не было.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Сообщить</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sat/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sat/strings.xml new file mode 100644 index 0000000000..037564fa1e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sat/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ᱤᱠᱟᱹ %1$s ᱥᱟᱶ ᱛᱮ ᱫᱤᱜᱫᱷᱟ ᱦᱚᱭ ᱱᱟ ᱟᱨ ᱨᱟᱹᱯᱩᱫᱮᱱᱟ ᱾</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s ᱴᱷᱮᱱ ᱨᱟᱹᱯᱩᱫ ᱨᱤᱯᱚᱨᱴ ᱠᱩᱞ ᱢᱮ</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ᱵᱚᱸᱫᱚᱭ ᱢᱮ</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ᱫᱩᱦᱲᱟᱹ ᱮᱦᱚᱵᱽ ᱢᱮ</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ᱨᱟᱹᱯᱩᱫ ᱠᱚ</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">ᱤᱠᱟᱹ ᱾ %1$s ᱨᱮ ᱵᱷᱩᱞ ᱦᱩᱭᱮᱱᱟ ᱾</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">ᱨᱤᱯᱚᱴ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s ᱴᱷᱮᱱ ᱨᱟᱹᱯᱩᱫ ᱨᱤᱯᱚᱴ ᱠᱩᱞ ᱦᱩᱭᱩ ᱠᱟᱱᱟ</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">ᱠᱨᱟᱥ ᱰᱟᱴᱟ ᱡᱟᱣᱨᱟᱜ ᱠᱟᱱᱟ</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">ᱠᱨᱟᱥ ᱴᱮᱞᱤᱢᱮᱴᱨᱤ ᱰᱟᱴᱟ ᱡᱟᱣᱨᱜ ᱠᱟᱱᱟ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">ᱨᱟᱹᱯᱩᱫ ᱨᱤᱯᱚᱨᱴ ᱠᱚ</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">ᱚᱠᱟ ᱦᱚᱸ ᱨᱟᱹᱯᱩᱫ ᱨᱤᱯᱚᱨᱴ ᱡᱚᱢᱟ ᱵᱟᱝ ᱦᱩᱭ ᱠᱟᱱᱟ ᱾</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">ᱦᱟᱹᱴᱤᱧ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sc/strings.xml new file mode 100644 index 0000000000..7e95e3a65e --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sc/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">%1$s at tentu unu problema e est faddidu.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Imbia s’informe de faddina a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Serra</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Torra a aviare %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Faddinas</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Ddoe est istada una faddina in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Sinnala</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Imbiende s’informe de faddina a %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Regollende datos de sa faddina</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Si sunt regollende is datos de telemetria de sa faddina</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Informes de faddinas</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nissunu informe de faddina imbiadu.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Cumpartzi</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-si/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-si/strings.xml new file mode 100644 index 0000000000..a58aed9c7c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-si/strings.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">කණගාටුයි. %1$s හි ගැටලුවක් මතු වී බිඳ වැටුණි.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">බිඳ වැටීමේ වාර්තාව %1$s වෙත යවන්න</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">වසන්න</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s යළි අරඹන්න</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">බිඳ වැටීම්</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">කණගාටුයි. %1$s හි ගැටලුවක් මතු විය.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">වාර්තාව</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">බිඳ වැටීමේ වාර්තාව %1$s වෙත යවමින්</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">බිඳ වැටීමේ දත්ත එකතැන් වෙමින්</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">බිඳවැටීම් වාර්තා</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">බිඳවැටීමේ වාර්තා කිසිවක් යොමු කර නැත.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">බෙදාගන්න</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sk/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000000..2d35f006a0 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sk/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Ospravedlňujeme sa. Aplikácia %1$s narazila na problém a zlyhala.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Odoslať správu o zlyhaní spoločnosti %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zavrieť</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Reštartovať %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Zlyhania</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Ospravedlňujeme sa. Vyskytol sa problém s aplikáciou %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Nahlásiť</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Odosielanie správy o zlyhaní spoločnosti %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Zhromažďujú sa údaje o zlyhaní</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Zhromažďujú sa telemetrické údaje o zlyhaní</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Správy o zlyhaní</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Neboli odoslané žiadne správy o zlyhaní.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Zdieľať</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-skr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-skr/strings.xml new file mode 100644 index 0000000000..6cda4101b6 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-skr/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">افسوس۔ %1$s وچ کوئی مسئلہ ہے تے تباہ تھی ڳئے۔</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s کوں کریش رپوٹ بھیڄو</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">بند کرو</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ولدا شروع کرو</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">کریش</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">افسوس۔ %1$s وچ ہک مسئلہ تھی ڳیا ہے۔</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">رپورٹ کرو</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s کوں کریش رپوٹ بھیڄیندا پئے</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">کریش ڈیٹا کٹھا کریندا پئے</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">کریش ٹیلی میٹری ڈیٹا کٹھا کرݨ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">کریش رپورٹاں</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">کوئی کریش رپوٹاں جمع کائنی کرائیاں۔</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">شیئر</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sl/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000..a0a020d1c4 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sl/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Oprostite. %1$s je naletel na težavo in se sesul.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Pošlji poročilo o sesutju organizaciji %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Zapri</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Ponovno zaženi %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Sesutja</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Oprostite. V %1$su je prišlo do težave.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Prijavi</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Pošiljanje poročila o sesutju organizaciji %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Zbiranje podatkov o sesutju</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Zbiranje telemetričnih podatkov o sesutju</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Poročila o sesutjih</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nobeno poročilo o sesutju ni bilo poslano.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Deli</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sq/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000000..90dadd927d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sq/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Na ndjeni. %1$s pati një problem dhe u vithis.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Dërgoni raport vithisjeje te %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Mbylle</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Rinise %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Vithisje</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Na ndjeni. Ndodhi një problem në %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Raportoje</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Po dërgohet njoftim vithisjeje te %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Po mblidhen të dhëna vithisjeje</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Po mblidhen të dhëna telemetrike vithisjeje</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Njoftime Vithisjesh</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nuk ka të parashtruar njoftime vithisjesh.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Ndajeni me të tjerët</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000000..1e5cefabbd --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sr/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Извињавам се. %1$s је имао проблем и срушио се.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Пошаљи извештај о паду на %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Затвори</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Поново покрени %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Пад</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Нажалост, догодио се проблем у позадинском процесу %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Извештај</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Слање извештаја о паду на %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Прикупљање података о паду</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Прикупљање телеметријских података о паду</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Извештаји о рушењу</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ниједан извештај о рушењу није поднесен.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Подели</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-su/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-su/strings.xml new file mode 100644 index 0000000000..e620497e6f --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-su/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Hampura. %1$s aya masalah tur ruksak.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Kirim laporan nu ruksak ka %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Tutup</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Mimitian deui %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Karuksakan</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Hampura. Aya masalah dina %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Laporan</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Kirim laporan nu ruksak ka %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Ngumpulkeun data ruksak</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Ngumpulkeun data telemétri ruksak</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Laporan Karuksakan</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Teu aya laporan karuksakan nu tos dipasihkeun.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Bagikeun</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..a4c7ed9511 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Förlåt. %1$s hade problem och kraschade.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Skicka kraschrapport till %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Stäng</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Starta om %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Krascher</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Förlåt. Ett problem uppstod i %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Rapportera</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Skicka kraschrapport till %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Samlar in kraschdata</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Samlar in krasch-telemetridata</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Kraschrapporter</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Inga kraschrapporter har skickats in.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Dela</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ta/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000000..39047ebb22 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ta/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">மன்னிக்க. %1$s சிக்கலேற்பட்டுச் செயலிழந்தது.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s க்குச் சிதைவு அறிக்கையை அனுப்பு</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">மூடுக</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s ஐ மறுதுவக்கு</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">செயலிழப்புகள்</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">அறிக்கையிடுக</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">செயலிழப்பு அறிக்கையை %1$s க்கு அனுப்புகிறது</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">சிதைவு அறிக்கைகள்</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">எந்தச் சிதைவு அறிக்கைகளும் சமர்பிக்கப்படவில்லை.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">பகிர்</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-te/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-te/strings.xml new file mode 100644 index 0000000000..3a7ea16d05 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-te/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">క్షమించండి. ఏదో సమస్య వల్ల %1$s క్రాష్ అయ్యింది.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">క్రాష్ నివేదికను %1$sకి పంపించు</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">మూసివేయి</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$sను పునఃప్రారంభించు</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">క్రాషులు</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">నివేదించు</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">క్రాష్ నివేదికను %1$s కి పంపిస్తోంది</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">క్రాష్ నివేదికలు</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">క్రాష్ నివేదికలేమీ సమర్పించబడలేదు.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">పంచుకోండి</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-tg/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-tg/strings.xml new file mode 100644 index 0000000000..ed7ddd9b30 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-tg/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Мутаассифона, %1$s мушкилӣ дошта, бо вайронӣ дучор шуд.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Фиристодани гузориш дар бораи вайронӣ ба %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Пӯшидан</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Аз нав оғоз кардани %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Вайрониҳо</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Бубахшед. Дар %1$s мушкилӣ ба миён омад.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Гузориш додан</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Гузориш дар бораи вайронӣ ба %1$s фиристода шуда истодааст</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Ҷамъоварии маълумот дар бораи вайрониҳо</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Ҷамъоварии маълумот дар бораи вайрониҳои дурсанҷӣ (телеметрия)</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Гузоришҳо дар бораи вайронӣ</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ягон гузориш дар бораи вайронӣ пешниҳод карда нашуд.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Мубодила кардан</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-th/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-th/strings.xml new file mode 100644 index 0000000000..9f1fe18638 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-th/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">ขออภัย %1$s มีปัญหา และหยุดการทำงานแล้ว</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">ส่งรายงานข้อขัดข้องไปยัง %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">ปิด</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">เริ่ม %1$s ใหม่</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">ข้อขัดข้อง</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">ขออภัย เกิดข้อผิดพลาดใน %1$s</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">รายงาน</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">กำลังส่งรายงานข้อขัดข้องไปยัง %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">กำลังรวบรวมข้อมูลข้อขัดข้อง</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">กำลังรวบรวมข้อมูลการวัดและส่งข้อมูลทางไกลเกี่ยวกับข้อขัดข้อง</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">รายงานข้อขัดข้อง</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">ยังไม่เคยมีการรายงานข้อขัดข้อง</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">แบ่งปัน</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-tl/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-tl/strings.xml new file mode 100644 index 0000000000..1b7d285a21 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-tl/strings.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Paumanhin. Nagkaproblema ang %1$s.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Ipadala ang crash report sa %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Isara</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">i-Restart ang %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Mga crash</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Iulat</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Ipinadadala ang crash report sa %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Nangongolekta ng data ng pag-crash</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Pagtitipon ng data ng telemetry ng pag-crash</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Mga Crash Report</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Walang pang mga crash report na naipadala.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Ibahagi</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-tr/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..c4b923ffb7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-tr/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">%1$s bir sorunla karşılaştı ve çöktü.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Çökme raporunu %1$s’ya gönder</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Kapat</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s uygulamasını yeniden başlat</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Çökmeler</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">%1$s uygulamasında bir sorun oluştu.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Raporla</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Çökme raporu %1$s\'ya gönderiliyor</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Çökme verileri toplanıyor</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Çökme verileri toplanıyor</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Çökme Raporları</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Henüz hiç çökme raporu gönderilmedi.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Paylaş</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-trs/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-trs/strings.xml new file mode 100644 index 0000000000..f5d345a3e7 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-trs/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sī ga\'man ruhuât. %1$s ga \'ngō sañuun riñanj.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Gā\'nïnj gan\'ānj nuguan\' rayi\'î sa \'iaj re\'ej riña %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Nārán</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Nāyi\'ì ñû %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Nej sa gahui a\'nan\'</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sī ga’man ruhuât. Huā sa gahui a’nan’ riña %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Gānātà\'</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Hīaj a\'nïn hua\'ānj nuguan\' rayi\'î sa \'iaj re\' riña %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Sa naran’ andaj gire’ riña aga’ nan</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Sa naran’ andaj telemetría gire’ riña aga’ nan</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Nuguan\' nata\' sa gahui a\'nan\'an</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Nu gan\'ānj gà\' si \'ngō nuguan\' ganata\'a.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Dūyingô\'</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-tt/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-tt/strings.xml new file mode 100644 index 0000000000..8056936c37 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-tt/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Гафу. %1$s хатага юлыкты һәм ватылды.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s адресына ватылу турында хәбәр җибәрү</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Ябу</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s-ны яңадан ачу</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Өзеклеклер</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Гафу итегез. %1$s кушымтасында проблема килеп чыкты.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Шикаять итү</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s адресына ватылу турында хәбәр җибәрү</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Ватылу турында мәгълүмат җыю</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Ватылу турында телеметрия мәгълүматларын туплау</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Ватылу турында хәбәрләр</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Ватылу турында хәбәрләр җибәрелмәгән.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Уртаклашу</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-tzm/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-tzm/strings.xml new file mode 100644 index 0000000000..9005336bc5 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-tzm/strings.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Mḍel</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Bḍu</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ug/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ug/strings.xml new file mode 100644 index 0000000000..d968d43ce4 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ug/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">كەچۈرۈڭ، %1$s مەسىلىگە يولۇقۇپ يىمىرىلدى.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">يىمىرىلىش دوكلاتىنى %1$s غا يوللايدۇ</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">تاقاش</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart"> %1$s نى قايتا قوزغات</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">يىمىرىلىش</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">كەچۈرۈڭ، %1$s دا مەسىلە كۆرۈلدى.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">دوكلات</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">يىمىرىلىش دوكلاتىنى %1$s غا يوللاۋاتىدۇ</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">يىمىرىلىش سانلىق مەلۇماتلىرىنى توپلاۋاتىدۇ</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">يىمىرىلىش تېلېگراف سانلىق مەلۇماتلىرىنى توپلاۋاتىدۇ</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">يىمىرىلىش دوكلاتى</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">يىمىرىلىش دوكلاتى تاپشۇرۇلمىدى.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">ھەمبەھىرلەش</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-uk/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..26c3f264f2 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-uk/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Вибачте. Виникла проблема з %1$s і стався збій.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Надіслати звіт про збій до %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Закрити</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Перезапустити %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Збої</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Вибачте. Виникла проблема в %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Звіт</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Надсилання звіту про збій до %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Збір даних про збої</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Збір даних телеметрії про збої</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Звіти про збої</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Жодних звітів про збої не надсилалось.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Поділитися</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-ur/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-ur/strings.xml new file mode 100644 index 0000000000..dfd0f6c7d5 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-ur/strings.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">معاف کریں، %1$s میں کوئی خرابی آئی ہے اور کریش ہو گئی ہے۔</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">%1$s کو کریش رپورٹ بھیجیں</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">بند کریں</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$s دوبارہ شروع کریں</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">کریش</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">رپورٹ</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$s کو کریش رپورٹ بھیجآ جا رہا ہے</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">کریش رپورٹیں</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">کوئی کریش رپورٹیں ارسال نہی کی گئی۔</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">شیئر کریں</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-uz/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-uz/strings.xml new file mode 100644 index 0000000000..41ca048ca5 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-uz/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Kechirasiz, %1$s ilovasida muammo yuz berdi.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Nosozlik maʼlumotini %1$sga yuborish</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Yopish</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">%1$sni qayta ishga tushirish</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Nosozliklar</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Kechirasiz. %1$s da muammo yuz berdi.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Hisobot berish</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">%1$sga nosozlik hisobotini yuborlmoqda</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Nosozlik maʼlumotlari yigʻilmoqda</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Nosozlik telemetriya maʼlumotlari yigʻilmoqda</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Nosozlik hisobotlari</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Hech qanday nosozlik hisobotlari yuborilmadi.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Ulashish</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-vec/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-vec/strings.xml new file mode 100644 index 0000000000..90f030e2f5 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-vec/strings.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Se xe verificà on problema en %1$s che gà provocà on aresto anomaƚo.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Manda na segnaƚasion de aresto anomaƚo a %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Sara su</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Invia de novo %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Aresto anomaƚo</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Segnaƚa</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Son drio mandare ƚa segnaƚasion de aresto anomaƚo a %1$s</string> + + </resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-vi/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000000..13cae1333c --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-vi/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Rất tiếc. %1$s đã gặp sự cố và buộc phải đóng.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Gửi báo cáo sự cố cho %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Đóng</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Khởi động lại %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Sự cố</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Xin lỗi. Đã xảy ra sự cố trong %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Báo cáo</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Gửi báo cáo sự cố đến %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Thu thập dữ liệu sự cố</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Thu thập dữ liệu đo từ xa của sự cố</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Trình báo cáo lỗi</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Chưa có báo cáo lỗi nào được gửi.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Chia sẻ</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-yo/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-yo/strings.xml new file mode 100644 index 0000000000..0676f335e1 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-yo/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Pẹ̀lẹ́. %1$s ní ìṣòro tó sì lulẹ̀.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Fi àwọn ìròyìn ìjákulẹ̀ ránṣẹ́ sí %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Padé</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Tún-un bẹ̀rẹ̀ %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Àwọn ìjákulẹ̀</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Pẹ̀lẹ́. Ìṣòrò kan wáyé ní %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Ìròyìn </string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Fifi àwọn ìròyìn ìjákulẹ̀ ránṣẹ́ sí %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Kíkó àwọn dátà tó ti ní ìjákulẹ̀ pọ̀</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Kíkó àwọn dátà tẹlímẹ́tírì tó ti ní ìjákulẹ̀ pọ̀</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Àwọn ìròyìn ìjákulẹ̀</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">Kò sí àwọn ìròyìn ìjákulẹ̀ tí a ti fi sílẹ̀.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Pín</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-zh-rCN/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..9a0bf4aadb --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">很抱歉,%1$s 遇到问题,已经崩溃。</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">向 %1$s 发送崩溃报告</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">关闭</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">重启 %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">崩溃信息</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">抱歉,%1$s 出现问题。</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">反馈</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">正在向 %1$s 发送崩溃报告</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">正在收集崩溃数据</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">收集崩溃遥测数据</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">崩溃报告</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">尚未提交任何崩溃报告。</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">共享</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values-zh-rTW/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..21177e0a22 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">很抱歉,%1$s 遇到問題,發生錯誤。</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">傳送錯誤報告給 %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">關閉</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">重新啟動 %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">程式錯誤</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">很抱歉,%1$s 發生問題。</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">回報</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">正在傳送錯誤報告給 %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">收集錯誤資料</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">正在取得發生錯誤的 telemetry 資料</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">錯誤資訊報表</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">未送出任何錯誤資訊報表。</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">分享</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values/strings.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values/strings.xml new file mode 100644 index 0000000000..c0403c6995 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values/strings.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<resources> + <!-- Title of the crash reporter dialog. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_dialog_title">Sorry. %1$s had a problem and crashed.</string> + + <!-- Label of the checkbox for sending crash reports in the crash reporter dialog. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_crash_dialog_checkbox">Send crash report to %1$s</string> + + <!-- Label of the button closing the crash reporter dialog. --> + <string name="mozac_lib_crash_dialog_button_close">Close</string> + + <!-- Label of the button closing the crash reporter dialog and restarting the app. --> + <string name="mozac_lib_crash_dialog_button_restart">Restart %1$s</string> + + <!-- Name of the "notification channel" used for displaying a notification when the app crashed and we can't show a prompt. See https://developer.android.com/training/notify-user/channels --> + <string name="mozac_lib_crash_channel">Crashes</string> + + <!-- Title of the crash reporter notification for background process crashes. %1$s will be replaced with the name of the app (e.g. Firefox Focus). --> + <string name="mozac_lib_crash_background_process_notification_title">Sorry. A problem occurred in %1$s.</string> + + <!-- Label of a notification action/button that will send the crash report to Mozilla. --> + <string name="mozac_lib_crash_notification_action_report">Report</string> + + <!-- Label of notification showing that the crash report service is running. %1$s will be replaced with the name of the organization (e.g. Mozilla). --> + <string name="mozac_lib_send_crash_report_in_progress">Sending crash report to %1$s</string> + + <!-- Label of notification showing that the crash handling service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_data_in_progress">Gathering crash data</string> + + <!-- Label of notification showing that the telemetry service is gathering the crash data. --> + <string name="mozac_lib_gathering_crash_telemetry_in_progress">Gathering crash telemetry data</string> + + <!-- Title of the activity that shows the list of past crashes (similar to about:crashes)--> + <string name="mozac_lib_crash_activity_title">Crash Reports</string> + + <!-- Text shown instead of crash list if no crashes have been submitted yet --> + <string name="mozac_lib_crash_no_crashes">No crash reports have been submitted.</string> + + <!-- Text link that will show an app chooser to share a crash report with a third-party app (e.g. gmail) --> + <string name="mozac_lib_crash_share">Share</string> +</resources> diff --git a/mobile/android/android-components/components/lib/crash/src/main/res/values/styles.xml b/mobile/android/android-components/components/lib/crash/src/main/res/values/styles.xml new file mode 100644 index 0000000000..8e441529e9 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/main/res/values/styles.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<resources> + <style name="Theme.Mozac.CrashReporter" parent="Theme.AppCompat.Light.Dialog"> + <item name="windowNoTitle">true</item> + <item name="android:windowMinWidthMajor">96%</item> + <item name="android:windowMinWidthMinor">96%</item> + <item name="android:windowAnimationStyle">@null</item> + </style> +</resources>
\ No newline at end of file diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt new file mode 100644 index 0000000000..b54947712d --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import java.lang.Thread.sleep +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class BreadcrumbTest { + + @Before + fun setUp() { + CrashReporter.reset() + } + + @Test + fun `RecordBreadCrumb stores breadCrumb in reporter`() { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.NEVER, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.recordCrashBreadcrumb( + Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ), + ) + + reporter.crashBreadcrumbsCopy().elementAt(0).let { + assertEquals(it.message, testMessage) + assertEquals(it.data, testData) + assertEquals(it.category, testCategory) + assertEquals(it.level, testLevel) + assertEquals(it.type, testType) + assertNotNull(it.date) + } + } + + @Test + fun `Reporter stores current number of breadcrumbs`() { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.NEVER, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.recordCrashBreadcrumb( + Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ), + ) + assertEquals(reporter.crashBreadcrumbsCopy().size, 1) + + reporter.recordCrashBreadcrumb( + Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ), + ) + assertEquals(reporter.crashBreadcrumbsCopy().size, 2) + + reporter.recordCrashBreadcrumb( + Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ), + ) + assertEquals(reporter.crashBreadcrumbsCopy().size, 3) + } + + @Test + fun `RecordBreadcumb stores correct date`() { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.NEVER, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val beginDate = Date() + sleep(100) // make sure time elapsed + reporter.recordCrashBreadcrumb( + Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ), + ) + sleep(100) // make sure time elapsed + val afterDate = Date() + + reporter.crashBreadcrumbsCopy().elementAt(0).let { + assertTrue(it.date.after(beginDate)) + assertTrue(it.date.before(afterDate)) + } + + val date = Date() + reporter.recordCrashBreadcrumb( + Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + date, + ), + ) + + reporter.crashBreadcrumbsCopy().elementAt(1).let { + assertEquals(it.date.compareTo(date), 0) + } + } + + @Test + fun `Breadcrumb converts correctly to JSON`() { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + val testDate = Date(0) + val testString = "{\"timestamp\":\"1970-01-01T00:00:00\",\"message\":\"test_Message\"," + + "\"category\":\"testing_category\",\"level\":\"Critical\",\"type\":\"User\"," + + "\"data\":{\"1\":\"one\",\"2\":\"two\"}}" + + val breadcrumb = Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + testDate, + ) + assertEquals(breadcrumb.toJson().toString(), testString) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt new file mode 100644 index 0000000000..1ec2333325 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt @@ -0,0 +1,931 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.service.CrashReporterService +import mozilla.components.lib.crash.service.CrashTelemetryService +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.expectException +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import java.lang.Thread.sleep +import java.lang.reflect.Modifier + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class CrashReporterTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Before + fun setUp() { + CrashReporter.reset() + } + + @Test + fun `Calling install() will setup uncaught exception handler`() { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + CrashReporter( + context = testContext, + services = listOf(mock()), + notificationsDelegate = mock(), + ).install(testContext) + + val newHandler = Thread.getDefaultUncaughtExceptionHandler() + assertNotNull(newHandler) + + assertNotEquals(defaultHandler, newHandler) + } + + @Test(expected = IllegalArgumentException::class) + fun `CrashReporter throws if no service is defined`() { + CrashReporter( + context = testContext, + services = emptyList(), + notificationsDelegate = mock(), + ).install(testContext) + } + + @Test + fun `CrashReporter will submit report immediately if setup with Prompt-NEVER`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.NEVER, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() + + reporter.onCrash(testContext, crash) + + verify(reporter).sendCrashTelemetry(testContext, crash) + verify(reporter).sendCrashReport(testContext, crash) + verify(reporter, never()).showPrompt(any(), eq(crash)) + } + + @Test + fun `CrashReporter will show prompt if setup with Prompt-ALWAYS`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.ALWAYS, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() + + reporter.onCrash(testContext, crash) + + verify(reporter).sendCrashTelemetry(testContext, crash) + verify(reporter, never()).sendCrashReport(testContext, crash) + verify(reporter).showPrompt(any(), eq(crash)) + } + + @Test + fun `CrashReporter will submit report immediately for non native crash and with setup Prompt-ONLY_NATIVE_CRASH`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() + + reporter.onCrash(testContext, crash) + + verify(reporter).sendCrashTelemetry(testContext, crash) + verify(reporter).sendCrashReport(testContext, crash) + verify(reporter, never()).showPrompt(any(), eq(crash)) + } + + @Test + fun `CrashReporter will show prompt for main process native crash and with setup Prompt-ONLY_NATIVE_CRASH`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + reporter.onCrash(testContext, crash) + + verify(reporter).sendCrashTelemetry(testContext, crash) + verify(reporter).showPrompt(any(), eq(crash)) + verify(reporter, never()).sendCrashReport(testContext, crash) + verify(service, never()).report(crash) + } + + @Test + fun `CrashReporter will submit crash telemetry even if crash report requires prompt`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.ALWAYS, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() + + reporter.onCrash(testContext, crash) + + verify(reporter).sendCrashTelemetry(testContext, crash) + verify(reporter, never()).sendCrashReport(testContext, crash) + verify(reporter).showPrompt(any(), eq(crash)) + } + + @Test + fun `CrashReporter will not prompt the user if there is no crash services`() { + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.ALWAYS, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() + + reporter.onCrash(testContext, crash) + + verify(reporter).sendCrashTelemetry(testContext, crash) + verify(reporter, never()).sendCrashReport(testContext, crash) + verify(reporter, never()).showPrompt(any(), eq(crash)) + } + + @Test + fun `CrashReporter will not send crash telemetry if there is no telemetry service`() { + val service: CrashReporterService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.ALWAYS, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() + + reporter.onCrash(testContext, crash) + + verify(reporter, never()).sendCrashTelemetry(testContext, crash) + verify(reporter).showPrompt(any(), eq(crash)) + } + + @Test + fun `Calling install() with no crash services or telemetry crash services will throw exception`() { + var exceptionThrown = false + + try { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + notificationsDelegate = mock(), + ).install(testContext) + } catch (e: IllegalArgumentException) { + exceptionThrown = true + } + + assert(exceptionThrown) + } + + @Test + fun `Calling install() with at least one crash service or telemetry crash service will not throw exception`() { + var exceptionThrown = false + + try { + CrashReporter( + context = testContext, + services = listOf(mock()), + notificationsDelegate = mock(), + ).install(testContext) + } catch (e: IllegalArgumentException) { + exceptionThrown = true + } + assert(!exceptionThrown) + + try { + CrashReporter( + context = testContext, + telemetryServices = listOf(mock()), + notificationsDelegate = mock(), + ).install(testContext) + } catch (e: IllegalArgumentException) { + exceptionThrown = true + } + assert(!exceptionThrown) + } + + @Test + fun `CrashReporter is enabled by default`() { + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH, + notificationsDelegate = mock(), + ).install(testContext), + ) + + assertTrue(reporter.enabled) + } + + @Test + fun `CrashReporter will not prompt and not submit report if not enabled`() { + val service: CrashReporterService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.ALWAYS, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.enabled = false + + val crash: Crash.UncaughtExceptionCrash = mock() + reporter.onCrash(testContext, crash) + + verify(reporter, never()).sendCrashReport(testContext, crash) + verify(reporter, never()).sendCrashTelemetry(testContext, crash) + verify(reporter, never()).showPrompt(any(), eq(crash)) + + verify(service, never()).report(crash) + } + + @Test + fun `CrashReporter sends telemetry`() { + val crash = createUncaughtExceptionCrash() + + val service = mock<CrashReporterService>() + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.NEVER, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.onCrash(testContext, crash) + verify(reporter, never()).sendCrashTelemetry(testContext, crash) + } + + @Test + fun `CrashReporter forwards uncaught exception crashes to service`() { + var exceptionCrash = false + + val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + + override fun report(crash: Crash.UncaughtExceptionCrash): String? { + exceptionCrash = true + return null + } + + override fun report(crash: Crash.NativeCodeCrash): String? = null + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? = null + } + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.NEVER, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.submitReport( + Crash.UncaughtExceptionCrash(0, RuntimeException(), arrayListOf()), + ).joinBlocking() + assertTrue(exceptionCrash) + } + + @Test + fun `CrashReporter forwards native crashes to service`() { + var nativeCrash = false + + val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null + + override fun report(crash: Crash.NativeCodeCrash): String? { + nativeCrash = true + return null + } + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? = null + } + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.NEVER, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.submitReport( + Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ), + ).joinBlocking() + assertTrue(nativeCrash) + } + + @Test + fun `CrashReporter forwards caught exception crashes to service`() = runTestOnMain { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + var exceptionCrash = false + var exceptionThrowable: Throwable? = null + var exceptionBreadcrumb: ArrayList<Breadcrumb>? = null + val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null + + override fun report(crash: Crash.NativeCodeCrash): String? = null + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? { + exceptionCrash = true + exceptionThrowable = throwable + exceptionBreadcrumb = breadcrumbs + return null + } + } + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.NEVER, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val throwable = RuntimeException() + val breadcrumb = Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ) + reporter.recordCrashBreadcrumb(breadcrumb) + advanceUntilIdle() + + reporter.submitCaughtException(throwable).joinBlocking() + + assertTrue(exceptionCrash) + assert(exceptionThrowable == throwable) + assert(exceptionBreadcrumb?.get(0) == breadcrumb) + } + + @Test + fun `Caught exception with no stack trace should be reported as CrashReporterException`() = runTestOnMain { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + var exceptionCrash = false + var exceptionThrowable: Throwable? = null + var exceptionBreadcrumb: ArrayList<Breadcrumb>? = null + val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null + + override fun report(crash: Crash.NativeCodeCrash): String? = null + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? { + exceptionCrash = true + exceptionThrowable = throwable + exceptionBreadcrumb = breadcrumbs + return null + } + } + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.NEVER, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val throwable = RuntimeException() + throwable.stackTrace = emptyArray() + val breadcrumb = Breadcrumb( + testMessage, + testData, + testCategory, + testLevel, + testType, + ) + reporter.recordCrashBreadcrumb(breadcrumb) + advanceUntilIdle() + + reporter.submitCaughtException(throwable).joinBlocking() + + assertTrue(exceptionCrash) + assert(exceptionThrowable is CrashReporterException.UnexpectedlyMissingStacktrace) + assert(exceptionThrowable?.cause is java.lang.RuntimeException) + assertEquals(exceptionBreadcrumb?.get(0), breadcrumb) + } + + @Test + fun `CrashReporter forwards native crashes to telemetry service`() { + var nativeCrash = false + + val telemetryService = object : CrashTelemetryService { + override fun record(crash: Crash.UncaughtExceptionCrash) = Unit + + override fun record(crash: Crash.NativeCodeCrash) { + nativeCrash = true + } + + override fun record(throwable: Throwable) = Unit + } + + val reporter = spy( + CrashReporter( + context = testContext, + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.NEVER, + notificationsDelegate = mock(), + ).install(testContext), + ) + + reporter.submitCrashTelemetry( + Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ), + ).joinBlocking() + assertTrue(nativeCrash) + } + + @Test + fun `Internal reference is set after calling install`() { + expectException(IllegalStateException::class) { + CrashReporter.requireInstance + } + + val reporter = CrashReporter( + context = testContext, + services = listOf(mock()), + notificationsDelegate = mock(), + ) + + expectException(IllegalStateException::class) { + CrashReporter.requireInstance + } + + reporter.install(testContext) + + assertNotNull(CrashReporter.requireInstance) + } + + @Test + fun `CrashReporter invokes PendingIntent if provided for foreground child process crashes`() { + val context = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = Intent("action") + val pendingIntent = spy(PendingIntent.getActivity(context, 0, intent, 0)) + + val reporter = CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(mock()), + nonFatalCrashIntent = pendingIntent, + notificationsDelegate = mock(), + ).install(testContext) + + val nativeCrash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + reporter.onCrash(context, nativeCrash) + + verify(pendingIntent).send(eq(context), eq(0), any()) + + val receivedIntent = shadowOf(context).nextStartedActivity + + val receivedCrash = Crash.fromIntent(receivedIntent) as? Crash.NativeCodeCrash + ?: throw AssertionError("Expected NativeCodeCrash instance") + + assertEquals(nativeCrash, receivedCrash) + assertEquals("dump.path", receivedCrash.minidumpPath) + assertEquals(true, receivedCrash.minidumpSuccess) + assertEquals("extras.path", receivedCrash.extrasPath) + assertEquals(false, receivedCrash.isFatal) + assertEquals(Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, receivedCrash.processType) + } + + @Test + fun `CrashReporter does not invoke PendingIntent if provided for main process crashes`() { + val context = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = Intent("action") + val pendingIntent = spy(PendingIntent.getActivity(context, 0, intent, 0)) + + val reporter = CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(mock()), + nonFatalCrashIntent = pendingIntent, + notificationsDelegate = mock(), + ).install(testContext) + + val nativeCrash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + reporter.onCrash(context, nativeCrash) + + verify(pendingIntent, never()).send(eq(context), eq(0), any()) + } + + @Test + fun `CrashReporter does not invoke PendingIntent if provided for background child process crashes`() { + val context = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = Intent("action") + val pendingIntent = spy(PendingIntent.getActivity(context, 0, intent, 0)) + + val reporter = CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(mock()), + nonFatalCrashIntent = pendingIntent, + notificationsDelegate = mock(), + ).install(context) + + val nativeCrash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + reporter.onCrash(context, nativeCrash) + + verify(pendingIntent, never()).send(eq(context), eq(0), any()) + } + + @Test + fun `CrashReporter sends telemetry but don't send native crash if the crash is in foreground child process and nonFatalPendingIntent is not null`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.NEVER, + nonFatalCrashIntent = mock(), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val nativeCrash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + reporter.onCrash(testContext, nativeCrash) + + verify(reporter, never()).sendCrashReport(testContext, nativeCrash) + verify(reporter, times(1)).sendCrashTelemetry(testContext, nativeCrash) + verify(reporter, never()).showPrompt(any(), eq(nativeCrash)) + } + + @Test + fun `CrashReporter sends telemetry and crash if the crash is in foreground child process and nonFatalPendingIntent is null`() { + val service: CrashReporterService = mock() + val telemetryService: CrashTelemetryService = mock() + + val reporter = spy( + CrashReporter( + context = testContext, + services = listOf(service), + telemetryServices = listOf(telemetryService), + shouldPrompt = CrashReporter.Prompt.NEVER, + scope = scope, + notificationsDelegate = mock(), + ).install(testContext), + ) + + val nativeCrash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + reporter.onCrash(testContext, nativeCrash) + + verify(reporter, times(1)).sendCrashReport(testContext, nativeCrash) + verify(reporter, times(1)).sendCrashTelemetry(testContext, nativeCrash) + verify(reporter, never()).showPrompt(any(), eq(nativeCrash)) + } + + @Test + fun `CrashReporter instance writes are visible across threads`() { + val instanceField = CrashReporter::class.java.getDeclaredField("instance") + assertTrue(Modifier.isVolatile(instanceField.modifiers)) + } + + @Test + fun `Breadcrumbs stores only max number of breadcrumbs`() = runTestOnMain { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testLevel = Breadcrumb.Level.CRITICAL + val testType = Breadcrumb.Type.USER + + var crashReporter = CrashReporter( + context = testContext, + services = listOf(mock()), + maxBreadCrumbs = 5, + scope = scope, + notificationsDelegate = mock(), + ) + + repeat(10) { + crashReporter.recordCrashBreadcrumb(Breadcrumb(testMessage, testData, testCategory, testLevel, testType)) + } + advanceUntilIdle() + assertEquals(crashReporter.crashBreadcrumbsCopy().size, 5) + + crashReporter = CrashReporter( + context = testContext, + services = listOf(mock()), + maxBreadCrumbs = 5, + scope = scope, + notificationsDelegate = mock(), + ) + repeat(15) { + crashReporter.recordCrashBreadcrumb(Breadcrumb(testMessage, testData, testCategory, testLevel, testType)) + } + advanceUntilIdle() + assertEquals(crashReporter.crashBreadcrumbsCopy().size, 5) + } + + @Test + fun `Breadcrumb priority queue stores the latest breadcrumbs`() = runTestOnMain { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testType = Breadcrumb.Type.USER + val maxNum = 10 + + var crashReporter = CrashReporter( + context = testContext, + services = listOf(mock()), + maxBreadCrumbs = maxNum, + scope = scope, + notificationsDelegate = mock(), + ) + + repeat(maxNum) { + crashReporter.recordCrashBreadcrumb( + Breadcrumb(testMessage, testData, testCategory, Breadcrumb.Level.CRITICAL, testType), + ) + sleep(10) // make sure time elapsed + } + advanceUntilIdle() + + crashReporter.crashBreadcrumbsCopy().let { + for (i in 0 until maxNum) { + assertEquals(it.elementAt(i).level, Breadcrumb.Level.CRITICAL) + } + + var time = it[0].date + for (i in 1 until it.size) { + assertTrue(time.before(it[i].date)) + time = it[i].date + } + } + + repeat(maxNum) { + crashReporter.recordCrashBreadcrumb( + Breadcrumb(testMessage, testData, testCategory, Breadcrumb.Level.DEBUG, testType), + ) + sleep(10) // make sure time elapsed + } + advanceUntilIdle() + + crashReporter.crashBreadcrumbsCopy().let { + for (i in 0 until maxNum) { + assertEquals(it.elementAt(i).level, Breadcrumb.Level.DEBUG) + } + + var time = it[0].date + for (i in 1 until it.size) { + assertTrue(time.before(it[i].date)) + time = it[i].date + } + } + } + + @Test + fun `Breadcrumb priority queue output list result is sorted by time`() = runTestOnMain { + val testMessage = "test_Message" + val testData = hashMapOf("1" to "one", "2" to "two") + val testCategory = "testing_category" + val testType = Breadcrumb.Type.USER + val maxNum = 10 + + var crashReporter = CrashReporter( + context = testContext, + services = listOf(mock()), + maxBreadCrumbs = 5, + scope = scope, + notificationsDelegate = mock(), + ) + + repeat(maxNum) { + crashReporter.recordCrashBreadcrumb( + Breadcrumb(testMessage, testData, testCategory, Breadcrumb.Level.DEBUG, testType), + ) + sleep(10) // make sure time elapsed + } + advanceUntilIdle() + + crashReporter.crashBreadcrumbsCopy().let { + var time = it[0].date + for (i in 1 until it.size) { + assertTrue(time.before(it[i].date)) + time = it[i].date + } + } + + repeat(maxNum / 2) { + crashReporter.recordCrashBreadcrumb( + Breadcrumb(testMessage, testData, testCategory, Breadcrumb.Level.INFO, testType), + ) + sleep(10) // make sure time elapsed + } + advanceUntilIdle() + + crashReporter.crashBreadcrumbsCopy().let { + var time = it[0].date + for (i in 1 until it.size) { + assertTrue(time.before(it[i].date)) + time = it[i].date + } + } + } +} + +private fun createUncaughtExceptionCrash(): Crash.UncaughtExceptionCrash { + return Crash.UncaughtExceptionCrash( + 0, + RuntimeException(), + ArrayList(), + ) +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashTest.kt new file mode 100644 index 0000000000..653655a65a --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashTest.kt @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CrashTest { + + @Test + fun `fromIntent() can deserialize a GeckoView crash Intent`() { + val originalCrash = Crash.NativeCodeCrash( + 123, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + true, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = "web", + ) + + val intent = Intent() + originalCrash.fillIn(intent) + + val recoveredCrash = Crash.fromIntent(intent) as? Crash.NativeCodeCrash + ?: throw AssertionError("Expected NativeCodeCrash instance") + + assertEquals(recoveredCrash.timestamp, 123) + assertEquals(recoveredCrash.minidumpSuccess, true) + assertEquals(recoveredCrash.isFatal, false) + assertEquals(recoveredCrash.processType, Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + recoveredCrash.minidumpPath, + ) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + recoveredCrash.extrasPath, + ) + assertEquals("web", recoveredCrash.remoteType) + } + + @Test + fun `Serialize and deserialize UncaughtExceptionCrash`() { + val exception = RuntimeException("Hello World!") + + val originalCrash = Crash.UncaughtExceptionCrash(0, exception, arrayListOf()) + + val intent = Intent() + originalCrash.fillIn(intent) + + val recoveredCrash = Crash.fromIntent(intent) as? Crash.UncaughtExceptionCrash + ?: throw AssertionError("Expected UncaughtExceptionCrash instance") + + assertEquals(exception, recoveredCrash.throwable) + assertEquals("Hello World!", recoveredCrash.throwable.message) + assertArrayEquals(exception.stackTrace, recoveredCrash.throwable.stackTrace) + } + + @Test + fun `isCrashIntent()`() { + assertFalse(Crash.isCrashIntent(Intent())) + + assertFalse( + Crash.isCrashIntent( + Intent() + .putExtra("crash", "I am a crash!"), + ), + ) + + assertTrue( + Crash.isCrashIntent( + Intent().apply { + Crash.UncaughtExceptionCrash(0, RuntimeException(), arrayListOf()).fillIn(this) + }, + ), + ) + + assertTrue( + Crash.isCrashIntent( + Intent().apply { + val crash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + "", + breadcrumbs = arrayListOf(), + remoteType = null, + ) + crash.fillIn(this) + }, + ), + ) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/NativeCodeCrashTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/NativeCodeCrashTest.kt new file mode 100644 index 0000000000..a8e83154b2 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/NativeCodeCrashTest.kt @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import android.content.ComponentName +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NativeCodeCrashTest { + + @Test + fun `Creating NativeCodeCrash object from sample GeckoView intent`() { + val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService", + ) + intent.putExtra("uuid", "afc91225-93d7-4328-b3eb-d26ad5af4d86") + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + ) + intent.putExtra("processType", "FOREGROUND_CHILD") + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + ) + intent.putExtra("minidumpSuccess", true) + intent.putExtra("remoteType", "web") + + val crash = Crash.NativeCodeCrash.fromBundle(intent.extras!!) + + assertEquals( + "afc91225-93d7-4328-b3eb-d26ad5af4d86", + crash.uuid, + ) + assertEquals(crash.minidumpSuccess, true) + assertEquals(crash.isFatal, false) + assertEquals(crash.processType, Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + crash.minidumpPath, + ) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + crash.extrasPath, + ) + assertEquals(crash.remoteType, "web") + } + + @Test + fun `to and from bundle`() { + val crash = Crash.NativeCodeCrash( + 0, + "minidumpPath", + true, + "extrasPath", + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + val bundle = crash.toBundle() + val otherCrash = Crash.NativeCodeCrash.fromBundle(bundle) + + assertEquals(crash, otherCrash) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/UncaughtExceptionCrashTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/UncaughtExceptionCrashTest.kt new file mode 100644 index 0000000000..f8400df289 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/UncaughtExceptionCrashTest.kt @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UncaughtExceptionCrashTest { + + @Test + fun `UncaughtExceptionCrash wraps exception`() { + val exception = RuntimeException("Kaput") + + val crash = Crash.UncaughtExceptionCrash(0, exception, arrayListOf()) + + assertEquals(exception, crash.throwable) + } + + @Test + fun `to and from bundle`() { + val exception = RuntimeException("Kaput") + val crash = Crash.UncaughtExceptionCrash(0, exception, arrayListOf()) + + val bundle = crash.toBundle() + val otherCrash = Crash.UncaughtExceptionCrash.fromBundle(bundle) + + assertEquals(crash, otherCrash) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt new file mode 100644 index 0000000000..14f607b038 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.handler + +import android.content.ComponentName +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.Robolectric + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class CrashHandlerServiceTest { + private var service: CrashHandlerService? = null + private var reporter: CrashReporter? = null + private val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Before + fun setUp() { + service = spy(Robolectric.setupService(CrashHandlerService::class.java)) + reporter = spy( + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, + services = listOf(mock()), + nonFatalCrashIntent = mock(), + scope = scope, + notificationsDelegate = mock(), + ), + ).install(testContext) + + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService", + ) + intent.putExtra( + "uuid", + "94f66ed7-50c7-41d1-96a7-299139a8c2af", + ) + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + ) + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + ) + intent.putExtra("minidumpSuccess", true) + + service!!.startService(intent) + } + + @After + fun tearDown() { + service!!.stopService(intent) + CrashReporter.reset() + } + + @Test + fun `CrashHandlerService forwards main process native code crash to crash reporter`() = runTestOnMain { + doNothing().`when`(reporter)!!.sendCrashReport(any(), any()) + + intent.putExtra("processType", "MAIN") + service!!.handleCrashIntent(intent, coroutinesTestRule.scope) + verify(reporter)!!.onCrash(any(), any()) + verify(reporter)!!.sendCrashReport(any(), any()) + verify(reporter, never())!!.sendNonFatalCrashIntent(any(), any()) + } + + @Test + fun `CrashHandlerService forwards foreground child process native code crash to crash reporter`() = runTestOnMain { + doNothing().`when`(reporter)!!.sendCrashReport(any(), any()) + + intent.putExtra("processType", "FOREGROUND_CHILD") + service!!.handleCrashIntent(intent, coroutinesTestRule.scope) + verify(reporter)!!.onCrash(any(), any()) + verify(reporter)!!.sendNonFatalCrashIntent(any(), any()) + verify(reporter, never())!!.sendCrashReport(any(), any()) + } + + @Test + fun `CrashHandlerService forwards background child process native code crash to crash reporter`() = runTestOnMain { + doNothing().`when`(reporter)!!.sendCrashReport(any(), any()) + + intent.putExtra("processType", "BACKGROUND_CHILD") + service!!.handleCrashIntent(intent, coroutinesTestRule.scope) + verify(reporter)!!.onCrash(any(), any()) + verify(reporter)!!.sendCrashReport(any(), any()) + verify(reporter, never())!!.sendNonFatalCrashIntent(any(), any()) + } + + @Test + fun `CrashHandlerService null intent in onStartCommand`() = runTestOnMain { + doNothing().`when`(service)!!.handleCrashIntent(any(), any()) + + service!!.onStartCommand(null, 0, 0) + + verify(service, times(0))!!.handleCrashIntent(any(), any()) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt new file mode 100644 index 0000000000..348c36df10 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.handler + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.service.CrashReporterService +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ExceptionHandlerTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Test + fun `ExceptionHandler forwards crashes to CrashReporter`() { + val service: CrashReporterService = mock() + + val crashReporter = spy( + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ), + ) + + val handler = ExceptionHandler( + testContext, + crashReporter, + ) + + val exception = RuntimeException("Hello World") + handler.uncaughtException(Thread.currentThread(), exception) + + verify(crashReporter).onCrash(eq(testContext), any()) + verify(crashReporter).sendCrashReport(eq(testContext), any()) + } + + @Test + fun `ExceptionHandler invokes default exception handler`() { + val defaultExceptionHandler: Thread.UncaughtExceptionHandler = mock() + + val crashReporter = CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, + services = listOf( + object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null + + override fun report(crash: Crash.NativeCodeCrash): String? = null + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? = null + }, + ), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val handler = ExceptionHandler( + testContext, + crashReporter, + defaultExceptionHandler, + ) + + verify(defaultExceptionHandler, never()).uncaughtException(any(), any()) + + val exception = RuntimeException() + handler.uncaughtException(Thread.currentThread(), exception) + + verify(defaultExceptionHandler).uncaughtException(Thread.currentThread(), exception) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/notification/CrashNotificationTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/notification/CrashNotificationTest.kt new file mode 100644 index 0000000000..ff4d8438ef --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/notification/CrashNotificationTest.kt @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.support.base.android.NotificationsDelegate +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class CrashNotificationTest { + @Test + fun shouldShowNotificationInsteadOfPrompt() { + val foregroundChildNativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 21)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 22)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 23)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 24)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 25)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 26)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 27)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 28)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 29)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 30)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(foregroundChildNativeCrash, sdkLevel = 31)) + + val mainProcessNativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 21)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 22)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 23)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 24)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 25)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 26)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 27)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 28)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 29)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 30)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(mainProcessNativeCrash, sdkLevel = 31)) + + val backgroundChildNativeCrash = Crash.NativeCodeCrash( + timestamp = 0, + minidumpPath = "", + minidumpSuccess = true, + extrasPath = "", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 21)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 22)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 23)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 24)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 25)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 26)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 27)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 28)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 29)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 30)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(backgroundChildNativeCrash, sdkLevel = 31)) + + val exceptionCrash = Crash.UncaughtExceptionCrash(0, RuntimeException("Boom"), arrayListOf()) + + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 21)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 22)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 23)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 24)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 25)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 26)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 27)) + assertFalse(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 28)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 29)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 30)) + assertTrue(CrashNotification.shouldShowNotificationInsteadOfPrompt(exceptionCrash, sdkLevel = 31)) + } + + @Test + fun `Showing notification`() { + val notificationManager = testContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadowNotificationManager = shadowOf(notificationManager) + + assertEquals(0, shadowNotificationManager.notificationChannels.size) + assertEquals(0, shadowNotificationManager.size()) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Boom"), arrayListOf()) + val notificationManagerCompat = spy(NotificationManagerCompat.from(testContext)) + val notificationsDelegate = NotificationsDelegate(notificationManagerCompat) + + whenever(notificationManagerCompat.areNotificationsEnabled()).thenReturn(true) + + val crashNotification = CrashNotification( + testContext, + crash, + CrashReporter.PromptConfiguration( + appName = "TestApp", + ), + notificationsDelegate = notificationsDelegate, + ) + crashNotification.show() + + assertEquals(1, shadowNotificationManager.notificationChannels.size) + assertEquals( + "Crashes", + (shadowNotificationManager.notificationChannels[0] as NotificationChannel).name, + ) + + assertEquals(1, shadowNotificationManager.size()) + } + + @Test + fun `not showing notification when permission is denied`() { + val notificationManager = testContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadowNotificationManager = shadowOf(notificationManager) + + assertEquals(0, shadowNotificationManager.notificationChannels.size) + assertEquals(0, shadowNotificationManager.size()) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Boom"), arrayListOf()) + val notificationManagerCompat = spy(NotificationManagerCompat.from(testContext)) + val notificationsDelegate = spy(NotificationsDelegate(notificationManagerCompat)) + + whenever(notificationManagerCompat.areNotificationsEnabled()).thenReturn(false) + + val crashNotification = CrashNotification( + testContext, + crash, + CrashReporter.PromptConfiguration( + appName = "TestApp", + ), + notificationsDelegate = notificationsDelegate, + ) + crashNotification.show() + + assertEquals(1, shadowNotificationManager.notificationChannels.size) + assertEquals( + "Crashes", + (shadowNotificationManager.notificationChannels[0] as NotificationChannel).name, + ) + + assertEquals(0, shadowNotificationManager.size()) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt new file mode 100644 index 0000000000..4459d75cae --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt @@ -0,0 +1,263 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.prompt + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import android.widget.TextView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ActivityScenario.launch +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.prompt.CrashReporterActivity.Companion.PREFERENCE_KEY_SEND_REPORT +import mozilla.components.lib.crash.prompt.CrashReporterActivity.Companion.SHARED_PREFERENCES_NAME +import mozilla.components.lib.crash.service.CrashReporterService +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations.openMocks +import kotlin.coroutines.CoroutineContext + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class CrashReporterActivityTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Mock + lateinit var service: CrashReporterService + + @Before + fun setUp() { + openMocks(this) + } + + @Test + fun `Pressing close button sends report`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Hello World"), arrayListOf()) + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + // When + activity.closeButton.performClick() + } + + // Await for all coroutines to be finished + advanceUntilIdle() + + // Then + verify(service).report(crash) + } + + @Test + fun `Pressing restart button sends report`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Hello World"), arrayListOf()) + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + // When + activity.restartButton.performClick() + } + + // Await for all coroutines to be finished + advanceUntilIdle() + + // Then + verify(service).report(crash) + } + + @Test + fun `Custom message is set on CrashReporterActivity`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + promptConfiguration = CrashReporter.PromptConfiguration( + message = "Hello World!", + theme = android.R.style.Theme_DeviceDefault, // Yolo! + ), + services = listOf(mock()), + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Hello World"), arrayListOf()) + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + // Then + assertEquals("Hello World!", activity.messageView.text) + } + } + + @Test + fun `Sending crash report saves checkbox state`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Hello World"), arrayListOf()) + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + // When + activity.sendCheckbox.isChecked = true + + // Then + assertFalse(activity.isSendReportPreferenceEnabled) + + // When + activity.restartButton.performClick() + + // Then + assertTrue(activity.isSendReportPreferenceEnabled) + } + } + + @Test + fun `Restart button visible for main process crash`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + assertEquals(activity.restartButton.visibility, View.VISIBLE) + } + } + + @Test + fun `Restart button hidden for background child process crash`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + assertEquals(activity.restartButton.visibility, View.GONE) + } + } + + @Test + fun `WHEN crash is native AND background child THEN is background returns true`() = runTestOnMain { + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(service), + scope = scope, + notificationsDelegate = mock(), + ).install(testContext) + + val crash = Crash.NativeCodeCrash( + 123, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + val scenario = coroutineContext.launchActivityWithCrash(crash) + + scenario.onActivity { activity -> + assert(activity.isRecoverableBackgroundCrash(crash)) + } + } +} + +/** + * Launch activity scenario for certain [crash]. + */ +@ExperimentalCoroutinesApi +private fun CoroutineContext.launchActivityWithCrash( + crash: Crash, +): ActivityScenario<CrashReporterActivity> = run { + val intent = Intent(testContext, CrashReporterActivity::class.java) + .also { crash.fillIn(it) } + + launch<CrashReporterActivity>(intent).apply { + onActivity { activity -> + activity.reporterCoroutineContext = this@run + } + } +} + +// Views +private val CrashReporterActivity.closeButton: Button get() = binding.closeButton +private val CrashReporterActivity.restartButton: Button get() = binding.restartButton +private val CrashReporterActivity.messageView: TextView get() = binding.messageView +private val CrashReporterActivity.sendCheckbox: CheckBox get() = binding.sendCheckbox + +// Preferences +private val CrashReporterActivity.preferences: SharedPreferences + get() = getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) +private val CrashReporterActivity.isSendReportPreferenceEnabled: Boolean + get() = preferences.getBoolean(PREFERENCE_KEY_SEND_REPORT, false) diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/GleanCrashReporterServiceTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/GleanCrashReporterServiceTest.kt new file mode 100644 index 0000000000..1097d3521f --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/GleanCrashReporterServiceTest.kt @@ -0,0 +1,464 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.GleanMetrics.CrashMetrics +import mozilla.components.service.glean.testing.GleanTestRule +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import java.io.File +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import mozilla.components.lib.crash.GleanMetrics.Crash as GleanCrash +import mozilla.components.lib.crash.GleanMetrics.Pings as GleanPings + +@RunWith(AndroidJUnit4::class) +class GleanCrashReporterServiceTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + @get:Rule + val gleanRule = GleanTestRule(context) + + private fun crashCountJson(key: String): String = "{\"type\":\"count\",\"label\":\"$key\"}" + + private fun crashPingJson(uptime: Long, type: String, time: Long, startup: Boolean): String = + "{\"type\":\"ping\",\"uptimeNanos\":$uptime,\"processType\":\"$type\"," + + "\"timeMillis\":$time,\"startup\":$startup,\"reason\":\"crash\"}" + + private fun crashPingJsonWithRemoteType( + uptime: Long, + type: String, + time: Long, + startup: Boolean, + remoteType: String, + ): String = + "{\"type\":\"ping\",\"uptimeNanos\":$uptime,\"processType\":\"$type\"," + + "\"timeMillis\":$time,\"startup\":$startup,\"reason\":\"crash\",\"remoteType\":\"$remoteType\"}" + + private fun exceptionPingJson(uptime: Long, time: Long, startup: Boolean): String = + "{\"type\":\"ping\",\"uptimeNanos\":$uptime,\"processType\":\"main\"," + + "\"timeMillis\":$time,\"startup\":$startup,\"reason\":\"crash\",\"cause\":\"java_exception\"}" + + @Test + fun `GleanCrashReporterService records all crash types`() { + val crashTypes = hashMapOf( + GleanCrashReporterService.MAIN_PROCESS_NATIVE_CODE_CRASH_KEY to Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ), + GleanCrashReporterService.FOREGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY to Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = "web", + ), + GleanCrashReporterService.BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY to Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ), + GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY to Crash.UncaughtExceptionCrash( + 0, + RuntimeException("Test"), + arrayListOf(), + ), + GleanCrashReporterService.CAUGHT_EXCEPTION_KEY to RuntimeException("Test"), + ) + + for ((type, crash) in crashTypes) { + // Because of how Glean is implemented, it can potentially persist information between + // tests or even between test classes, so we compensate by capturing the initial value + // to compare to. + val initialValue = try { + CrashMetrics.crashCount[type].testGetValue()!! + } catch (e: NullPointerException) { + 0 + } + + run { + val service = spy(GleanCrashReporterService(context)) + + assertFalse("No previous persisted crashes must exist", service.file.exists()) + + when (crash) { + is Crash.NativeCodeCrash -> service.record(crash) + is Crash.UncaughtExceptionCrash -> service.record(crash) + is Throwable -> service.record(crash) + } + + assertTrue("Persistence file must exist", service.file.exists()) + val lines = service.file.readLines() + assertEquals( + "Must be $type", + crashCountJson(type), + lines.first(), + ) + } + + // Initialize a fresh GleanCrashReporterService and ensure metrics are recorded in Glean + run { + GleanCrashReporterService(context) + + assertEquals( + "Glean must record correct value", + 1, + CrashMetrics.crashCount[type].testGetValue()!! - initialValue, + ) + } + } + } + + @Test + fun `GleanCrashReporterService correctly handles multiple crashes in a single file`() { + val initialExceptionValue = try { + CrashMetrics.crashCount[GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY].testGetValue()!! + } catch (e: NullPointerException) { + 0 + } + val initialMainProcessNativeCrashValue = try { + CrashMetrics.crashCount[GleanCrashReporterService.MAIN_PROCESS_NATIVE_CODE_CRASH_KEY].testGetValue()!! + } catch (e: NullPointerException) { + 0 + } + + val initialForegroundChildProcessNativeCrashValue = try { + CrashMetrics.crashCount[GleanCrashReporterService.FOREGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY].testGetValue()!! + } catch (e: NullPointerException) { + 0 + } + + val initialBackgroundChildProcessNativeCrashValue = try { + CrashMetrics.crashCount[GleanCrashReporterService.BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY].testGetValue()!! + } catch (e: NullPointerException) { + 0 + } + + run { + val service = spy(GleanCrashReporterService(context)) + + assertFalse("No previous persisted crashes must exist", service.file.exists()) + + val uncaughtExceptionCrash = + Crash.UncaughtExceptionCrash(0, RuntimeException("Test"), arrayListOf()) + val mainProcessNativeCodeCrash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + val foregroundChildProcessNativeCodeCrash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = "web", + ) + val backgroundChildProcessNativeCodeCrash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + val extensionProcessNativeCodeCrash = Crash.NativeCodeCrash( + 0, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_BACKGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = "extension", + ) + + // Record some crashes + service.record(uncaughtExceptionCrash) + service.record(mainProcessNativeCodeCrash) + service.record(uncaughtExceptionCrash) + service.record(foregroundChildProcessNativeCodeCrash) + service.record(backgroundChildProcessNativeCodeCrash) + service.record(extensionProcessNativeCodeCrash) + + // Make sure the file exists + assertTrue("Persistence file must exist", service.file.exists()) + + // Get the file lines + val lines = service.file.readLines().iterator() + assertEquals( + "element must be uncaught exception", + crashCountJson(GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY), + lines.next(), + ) + assertEquals( + "element must be uncaught exception ping", + exceptionPingJson(0, 0, false), + lines.next(), + ) + assertEquals( + "element must be main process native code crash", + crashCountJson(GleanCrashReporterService.MAIN_PROCESS_NATIVE_CODE_CRASH_KEY), + lines.next(), + ) + assertEquals( + "element must be main process crash ping", + crashPingJson(0, "main", 0, false), + lines.next(), + ) + assertEquals( + "element must be uncaught exception", + crashCountJson(GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY), + lines.next(), // skip crash ping line in this test + ) + assertEquals( + "element must be uncaught exception ping", + exceptionPingJson(0, 0, false), + lines.next(), + ) + assertEquals( + "element must be foreground child process native code crash", + crashCountJson(GleanCrashReporterService.FOREGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY), + lines.next(), + ) + assertEquals( + "element must be foreground process crash ping", + crashPingJsonWithRemoteType(0, "content", 0, false, "web"), + lines.next(), + ) + assertEquals( + "element must be background child process native code crash", + crashCountJson(GleanCrashReporterService.BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY), + lines.next(), // skip crash ping line + ) + assertEquals( + "element must be background process crash ping", + crashPingJson(0, "utility", 0, false), + lines.next(), + ) + assertEquals( + "element must be background child process native code crash", + crashCountJson(GleanCrashReporterService.BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY), + lines.next(), + ) + assertEquals( + "element must be extensions process crash ping", + crashPingJsonWithRemoteType(0, "content", 0, false, "extension"), + lines.next(), + ) + assertFalse(lines.hasNext()) + } + + // Initialize a fresh GleanCrashReporterService and ensure metrics are recorded in Glean + run { + GleanCrashReporterService(context) + + assertEquals( + "Glean must record correct value", + 2, + CrashMetrics.crashCount[GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY].testGetValue()!! - initialExceptionValue, + ) + assertEquals( + "Glean must record correct value", + 1, + CrashMetrics.crashCount[GleanCrashReporterService.MAIN_PROCESS_NATIVE_CODE_CRASH_KEY].testGetValue()!! - initialMainProcessNativeCrashValue, + ) + assertEquals( + "Glean must record correct value", + 1, + CrashMetrics.crashCount[GleanCrashReporterService.FOREGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY].testGetValue()!! - initialForegroundChildProcessNativeCrashValue, + ) + assertEquals( + "Glean must record correct value", + 2, + CrashMetrics.crashCount[GleanCrashReporterService.BACKGROUND_CHILD_PROCESS_NATIVE_CODE_CRASH_KEY].testGetValue()!! - initialBackgroundChildProcessNativeCrashValue, + ) + } + } + + @Test + fun `GleanCrashReporterService does not crash if it can't write to it's file`() { + val file = + spy(File(context.applicationInfo.dataDir, GleanCrashReporterService.CRASH_FILE_NAME)) + whenever(file.canWrite()).thenReturn(false) + val service = spy(GleanCrashReporterService(context, file)) + + assertFalse("No previous persisted crashes must exist", service.file.exists()) + + val crash = Crash.UncaughtExceptionCrash(0, RuntimeException("Test"), arrayListOf()) + service.record(crash) + + assertTrue("Persistence file must exist", service.file.exists()) + val lines = service.file.readLines() + assertEquals("Must be empty due to mocked write error", 0, lines.count()) + } + + @Test + fun `GleanCrashReporterService does not crash if the persistent file is corrupted`() { + // Because of how Glean is implemented, it can potentially persist information between + // tests or even between test classes, so we compensate by capturing the initial value + // to compare to. + val initialValue = try { + CrashMetrics.crashCount[GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY].testGetValue()!! + } catch (e: NullPointerException) { + 0 + } + + run { + val service = spy(GleanCrashReporterService(context)) + + assertFalse("No previous persisted crashes must exist", service.file.exists()) + + val crash = Crash.UncaughtExceptionCrash( + 0, + RuntimeException("Test"), + arrayListOf(), + ) + service.record(crash) + + assertTrue("Persistence file must exist", service.file.exists()) + + // Add bad data + service.file.appendText("bad data in here\n") + + val lines = service.file.readLines() + assertEquals( + "must be native code crash", + "{\"type\":\"count\",\"label\":\"${GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY}\"}", + lines.first(), + ) + assertEquals( + "must be uncaught exception ping", + exceptionPingJson(0, 0, false), + lines[1], + ) + assertEquals("bad data in here", lines[2]) + } + + run { + GleanCrashReporterService(context) + + assertEquals( + "Glean must record correct value", + 1, + CrashMetrics.crashCount[GleanCrashReporterService.UNCAUGHT_EXCEPTION_KEY].testGetValue()!! - initialValue, + ) + } + } + + @Test + fun `GleanCrashReporterService sends crash pings`() { + val service = spy(GleanCrashReporterService(context)) + + val crash = Crash.NativeCodeCrash( + 12340000, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + service.record(crash) + + assertTrue("Persistence file must exist", service.file.exists()) + + val lines = service.file.readLines() + assertEquals( + "First element must be main process native code crash", + crashCountJson(GleanCrashReporterService.MAIN_PROCESS_NATIVE_CODE_CRASH_KEY), + lines[0], + ) + assertEquals( + "Second element must be main process crash ping", + crashPingJson(0, "main", 12340000, false), + lines[1], + ) + + run { + var pingReceived = false + GleanPings.crash.testBeforeNextSubmit { _ -> + val date = GregorianCalendar().apply { + time = Date(12340000) + } + date.set(Calendar.SECOND, 0) + date.set(Calendar.MILLISECOND, 0) + assertEquals(date.time, GleanCrash.time.testGetValue()) + assertEquals(0L, GleanCrash.uptime.testGetValue()) + assertEquals("main", GleanCrash.processType.testGetValue()) + assertEquals(false, GleanCrash.startup.testGetValue()) + assertEquals("os_fault", GleanCrash.cause.testGetValue()) + assertEquals("", GleanCrash.remoteType.testGetValue()) + pingReceived = true + } + + GleanCrashReporterService(context) + assertTrue("Expected ping to be sent", pingReceived) + } + } + + @Test + fun `GleanCrashReporterService serialized pings are forward compatible`() { + val service = spy(GleanCrashReporterService(context)) + + // Original ping fields (no e.g. `cause` field) + service.file.appendText( + "{\"type\":\"ping\",\"uptimeNanos\":0,\"processType\":\"main\"," + + "\"timeMillis\":0,\"startup\":false,\"reason\":\"crash\"}\n", + ) + + assertTrue("Persistence file must exist", service.file.exists()) + + run { + var pingReceived = false + GleanPings.crash.testBeforeNextSubmit { _ -> + val date = GregorianCalendar().apply { + time = Date(0) + } + date.set(Calendar.SECOND, 0) + date.set(Calendar.MILLISECOND, 0) + assertEquals(date.time, GleanCrash.time.testGetValue()) + assertEquals(0L, GleanCrash.uptime.testGetValue()) + assertEquals("main", GleanCrash.processType.testGetValue()) + assertEquals(false, GleanCrash.startup.testGetValue()) + assertEquals("os_fault", GleanCrash.cause.testGetValue()) + assertEquals("", GleanCrash.remoteType.testGetValue()) + pingReceived = true + } + + GleanCrashReporterService(context) + assertTrue("Expected ping to be sent", pingReceived) + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt new file mode 100644 index 0000000000..0b2ace44fd --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt @@ -0,0 +1,693 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.io.Resources.getResource +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.Crash +import mozilla.components.support.test.any +import mozilla.components.support.test.robolectric.testContext +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStreamReader +import java.util.zip.GZIPInputStream + +@RunWith(AndroidJUnit4::class) +class MozillaSocorroServiceTest { + + @Test + fun `MozillaSocorroService sends native code crashes to GeckoView crash reporter`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + doReturn("").`when`(service).sendReport(anyLong(), any(), any(), any(), anyBoolean(), anyBoolean(), any()) + + val crash = Crash.NativeCodeCrash( + 123, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + service.report(crash) + + verify(service).report(crash) + verify(service).sendReport(123, null, crash.minidumpPath, crash.extrasPath, true, false, crash.breadcrumbs) + } + + @Test + fun `MozillaSocorroService generated server URL have no spaces`() { + val service = MozillaSocorroService( + testContext, + "Test App", + versionName = "test version name", + ) + + assertFalse(service.serverUrl!!.contains(" ")) + assertFalse(service.serverUrl!!.contains("}")) + assertFalse(service.serverUrl!!.contains("{")) + } + + @Test + fun `MozillaSocorroService send uncaught exception crashes`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + doReturn("").`when`(service).sendReport(anyLong(), any(), any(), any(), anyBoolean(), anyBoolean(), any()) + + val crash = Crash.UncaughtExceptionCrash(123, RuntimeException("Test"), arrayListOf()) + service.report(crash) + + verify(service).report(crash) + verify(service).sendReport(123, crash.throwable, null, null, false, true, crash.breadcrumbs) + } + + @Test + fun `MozillaSocorroService do not send caught exception`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + doReturn("").`when`(service).sendReport(anyLong(), any(), any(), any(), anyBoolean(), anyBoolean(), any()) + val throwable = RuntimeException("Test") + val breadcrumbs: ArrayList<Breadcrumb> = arrayListOf() + val id = service.report(throwable, breadcrumbs) + + verify(service).report(throwable, breadcrumbs) + verify(service, never()).sendReport(anyLong(), any(), any(), any(), anyBoolean(), anyBoolean(), any()) + assertNull(id) + } + + @Test + fun `MozillaSocorroService native fatal crash request is correct`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.NativeCodeCrash( + 123456, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + service.report(crash) + + val fileInputStream = + ByteArrayInputStream(mockWebServer.takeRequest().body.inputStream().readBytes()) + val inputStream = GZIPInputStream(fileInputStream) + val reader = InputStreamReader(inputStream) + val bufferedReader = BufferedReader(reader) + val request = bufferedReader.readText() + + assert(request.contains("name=Android_ProcessName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=ProductID\r\n\r\n{aa3c5121-dab2-40e2-81ca-7ea25febc110}")) + assert(request.contains("name=Vendor\r\n\r\nN/A")) + assert(request.contains("name=ReleaseChannel\r\n\r\nN/A")) + assert(request.contains("name=Android_PackageName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=Android_Device\r\n\r\nrobolectric")) + assert(request.contains("name=CrashType\r\n\r\n$FATAL_NATIVE_CRASH_TYPE")) + assert(request.contains("name=CrashTime\r\n\r\n123")) + assert(request.contains("name=useragent_locale\r\n\r\nen-US")) + + verify(service).report(crash) + verify(service).sendReport(123456, null, "dump.path", "extras.path", true, true, crash.breadcrumbs) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `incorrect file extension is ignored in native fatal crash requests`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.NativeCodeCrash( + 123456, + "test/minidumps/3fa772dc-dc89-c08d-c03e-7f441c50821e.ini", + true, + "test/file/66dd8af2-643c-ca11-5178-e61c6819f827", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + doReturn(HashMap<String, String>()).`when`(service).readExtrasFromFile(any()) + doNothing().`when`(service).sendFile(any(), any(), any(), any(), any()) + service.report(crash) + + verify(service).report(crash) + verify(service, times(0)).readExtrasFromFile(any()) + verify(service, times(0)).sendFile(any(), any(), any(), any(), any()) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `incorrect file format is ignored in native fatal crash requests`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.NativeCodeCrash( + 123456, + "test/minidumps/test.dmp", + true, + "test/file/test.extra", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + doReturn(HashMap<String, String>()).`when`(service).readExtrasFromFile(any()) + doNothing().`when`(service).sendFile(any(), any(), any(), any(), any()) + service.report(crash) + + verify(service).report(crash) + verify(service, times(0)).readExtrasFromFile(any()) + verify(service, times(0)).sendFile(any(), any(), any(), any(), any()) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `correct file format is used in native fatal crash requests`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.NativeCodeCrash( + 123456, + "test/minidumps/3fa772dc-dc89-c08d-c03e-7f441c50821e.dmp", + true, + "test/file/66dd8af2-643c-ca11-5178-e61c6819f827.extra", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + doReturn(HashMap<String, String>()).`when`(service).readExtrasFromFile(any()) + doNothing().`when`(service).sendFile(any(), any(), any(), any(), any()) + service.report(crash) + + verify(service).report(crash) + verify(service).readExtrasFromFile(any()) + verify(service).sendFile(any(), any(), any(), any(), any()) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `MozillaSocorroService parameters is reported correctly`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + version = "test version", + buildId = "test build id", + vendor = "test vendor", + serverUrl = serverUrl.toString(), + versionName = "1.0.1", + versionCode = "1000", + releaseChannel = "test channel", + distributionId = "test distribution id", + ), + ) + + val crash = Crash.NativeCodeCrash( + 123456, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + service.report(crash) + + val fileInputStream = + ByteArrayInputStream(mockWebServer.takeRequest().body.inputStream().readBytes()) + val inputStream = GZIPInputStream(fileInputStream) + val reader = InputStreamReader(inputStream) + val bufferedReader = BufferedReader(reader) + val request = bufferedReader.readText() + + assert(request.contains("name=Android_ProcessName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=ProductID\r\n\r\n{aa3c5121-dab2-40e2-81ca-7ea25febc110}")) + assert(request.contains("name=Vendor\r\n\r\ntest vendor")) + assert(request.contains("name=ReleaseChannel\r\n\r\ntest channel")) + assert(request.contains("name=Android_PackageName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=Android_Device\r\n\r\nrobolectric")) + assert(request.contains("name=CrashType\r\n\r\n$FATAL_NATIVE_CRASH_TYPE")) + assert(request.contains("name=CrashTime\r\n\r\n123")) + assert(request.contains("name=GeckoViewVersion\r\n\r\ntest version")) + assert(request.contains("name=BuildID\r\n\r\ntest build id")) + assert(request.contains("name=Version\r\n\r\n1.0.1")) + assert(request.contains("name=ApplicationBuildID\r\n\r\n1000")) + assert(request.contains("name=useragent_locale\r\n\r\nen-US")) + assert(request.contains("name=DistributionID\r\n\r\ntest distribution id")) + + verify(service).report(crash) + verify(service).sendReport(123456, null, "dump.path", "extras.path", true, true, crash.breadcrumbs) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `MozillaSocorroService native non-fatal crash request is correct`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + vendor = "Mozilla", + releaseChannel = "nightly", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.NativeCodeCrash( + 123456, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + service.report(crash) + + val fileInputStream = + ByteArrayInputStream(mockWebServer.takeRequest().body.inputStream().readBytes()) + val inputStream = GZIPInputStream(fileInputStream) + val reader = InputStreamReader(inputStream) + val bufferedReader = BufferedReader(reader) + val request = bufferedReader.readText() + + assert(request.contains("name=Android_ProcessName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=ProductID\r\n\r\n{aa3c5121-dab2-40e2-81ca-7ea25febc110}")) + assert(request.contains("name=Vendor\r\n\r\nMozilla")) + assert(request.contains("name=ReleaseChannel\r\n\r\nnightly")) + assert(request.contains("name=Android_PackageName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=Android_Device\r\n\r\nrobolectric")) + assert(request.contains("name=CrashType\r\n\r\n$NON_FATAL_NATIVE_CRASH_TYPE")) + assert(request.contains("name=CrashTime\r\n\r\n123")) + assert(request.contains("name=useragent_locale\r\n\r\nen-US")) + + verify(service).report(crash) + verify(service).sendReport(123456, null, "dump.path", "extras.path", true, false, crash.breadcrumbs) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `MozillaSocorroService uncaught exception request is correct`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + appId = "{aa3c5121-dab2-40e2-81ca-7ea25febc110}", + vendor = "Mozilla", + releaseChannel = "nightly", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.UncaughtExceptionCrash(123456, RuntimeException("Test"), arrayListOf()) + service.report(crash) + + val fileInputStream = + ByteArrayInputStream(mockWebServer.takeRequest().body.inputStream().readBytes()) + val inputStream = GZIPInputStream(fileInputStream) + val reader = InputStreamReader(inputStream) + val bufferedReader = BufferedReader(reader) + val request = bufferedReader.readText() + + assert(request.contains("name=JavaStackTrace\r\n\r\njava.lang.RuntimeException: Test")) + assert(request.contains("name=JavaException\r\n\r\n{\"exception\":{\"values\":[{\"stacktrace\":{\"frames\":[{\"module\":\"mozilla.components.lib.crash.service.MozillaSocorroServiceTest\",\"function\":\"MozillaSocorroService uncaught exception request is correct\",\"in_app\":true")) + assert(request.contains("name=Android_ProcessName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=ProductID\r\n\r\n{aa3c5121-dab2-40e2-81ca-7ea25febc110}")) + assert(request.contains("name=Vendor\r\n\r\nMozilla")) + assert(request.contains("name=ReleaseChannel\r\n\r\nnightly")) + assert(request.contains("name=Android_PackageName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=Android_Device\r\n\r\nrobolectric")) + assert(request.contains("name=CrashType\r\n\r\n$UNCAUGHT_EXCEPTION_TYPE")) + assert(request.contains("name=CrashTime\r\n\r\n123")) + assert(request.contains("name=useragent_locale\r\n\r\nen-US")) + + verify(service).report(crash) + verify(service).sendReport(123456, crash.throwable, null, null, false, true, crash.breadcrumbs) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `MozillaSocorroService handles 200 response correctly`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.UncaughtExceptionCrash(123, RuntimeException("Test"), arrayListOf()) + service.report(crash) + + mockWebServer.shutdown() + verify(service).report(crash) + verify(service).sendReport(123, crash.throwable, null, null, false, true, crash.breadcrumbs) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `MozillaSocorroService handles 404 response correctly`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue(MockResponse().setResponseCode(404).setBody("error")) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + serverUrl = serverUrl.toString(), + ), + ) + + val crash = Crash.NativeCodeCrash( + 123, + null, + true, + null, + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + service.report(crash) + mockWebServer.shutdown() + + verify(service).report(crash) + verify(service).sendReport(123, null, crash.minidumpPath, crash.extrasPath, true, false, crash.breadcrumbs) + } finally { + mockWebServer.shutdown() + } + } + + @Test + fun `MozillaSocorroService parses extrasFile correctly`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + val file = File(getResource("TestExtrasFile").file) + val extrasMap = service.readExtrasFromFile(file) + + assertEquals(extrasMap.size, 25) + assertEquals(extrasMap["ContentSandboxLevel"], "2") + assertEquals(extrasMap["TelemetryEnvironment"], "{\"EscapedField\":\"EscapedData\n\nfoo\"}") + assertEquals(extrasMap["EMCheckCompatibility"], "true") + assertEquals(extrasMap["ProductName"], "Firefox") + assertEquals(extrasMap["ContentSandboxCapabilities"], "119") + assertEquals(extrasMap["TelemetryClientId"], "") + assertEquals(extrasMap["Vendor"], "Mozilla") + assertEquals(extrasMap["InstallTime"], "1000000000") + assertEquals(extrasMap["Theme"], "classic/1.0") + assertEquals(extrasMap["ReleaseChannel"], "default") + assertEquals(extrasMap["SafeMode"], "0") + assertEquals(extrasMap["ContentSandboxCapable"], "1") + assertEquals(extrasMap["useragent_locale"], "en-US") + assertEquals(extrasMap["Version"], "55.0a1") + assertEquals(extrasMap["BuildID"], "20170512114708") + assertEquals(extrasMap["ProductID"], "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}") + assertEquals(extrasMap["TelemetryServerURL"], "") + assertEquals(extrasMap["DOMIPCEnabled"], "1") + assertEquals(extrasMap["Add-ons"], "") + assertEquals(extrasMap["CrashTime"], "1494582646") + assertEquals(extrasMap["UptimeTS"], "14.9179586") + assertEquals(extrasMap["ThreadIdNameMapping"], "") + assertEquals(extrasMap["ContentSandboxEnabled"], "1") + assertEquals(extrasMap["StartupTime"], "1000000000") + assertFalse(extrasMap.contains("URL")) + assertFalse(extrasMap.contains("ServerURL")) + assertFalse(extrasMap.contains("StackTraces")) + } + + @Test + fun `MozillaSocorroService parses legacyExtraFile correctly`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + val file = File(getResource("TestLegacyExtrasFile").file) + val extrasMap = service.readExtrasFromFile(file) + + assertEquals(extrasMap.size, 25) + assertEquals(extrasMap["ContentSandboxLevel"], "2") + assertEquals(extrasMap["TelemetryEnvironment"], "{\"EscapedField\":\"EscapedData\n\nfoo\"}") + assertEquals(extrasMap["EMCheckCompatibility"], "true") + assertEquals(extrasMap["ProductName"], "Firefox") + assertEquals(extrasMap["ContentSandboxCapabilities"], "119") + assertEquals(extrasMap["TelemetryClientId"], "") + assertEquals(extrasMap["Vendor"], "Mozilla") + assertEquals(extrasMap["InstallTime"], "1000000000") + assertEquals(extrasMap["Theme"], "classic/1.0") + assertEquals(extrasMap["ReleaseChannel"], "default") + assertEquals(extrasMap["SafeMode"], "0") + assertEquals(extrasMap["ContentSandboxCapable"], "1") + assertEquals(extrasMap["useragent_locale"], "en-US") + assertEquals(extrasMap["Version"], "55.0a1") + assertEquals(extrasMap["BuildID"], "20170512114708") + assertEquals(extrasMap["ProductID"], "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}") + assertEquals(extrasMap["TelemetryServerURL"], "") + assertEquals(extrasMap["DOMIPCEnabled"], "1") + assertEquals(extrasMap["Add-ons"], "") + assertEquals(extrasMap["CrashTime"], "1494582646") + assertEquals(extrasMap["UptimeTS"], "14.9179586") + assertEquals(extrasMap["ThreadIdNameMapping"], "") + assertEquals(extrasMap["ContentSandboxEnabled"], "1") + assertEquals(extrasMap["StartupTime"], "1000000000") + assertFalse(extrasMap.contains("URL")) + assertFalse(extrasMap.contains("ServerURL")) + assertFalse(extrasMap.contains("StackTraces")) + } + + @Test + fun `MozillaSocorroService handles bad extrasFile correctly`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + val file = File(getResource("BadTestExtrasFile").file) + val extrasMap = service.readExtrasFromFile(file) + + assertEquals(extrasMap.size, 0) + } + + @Test + fun `MozillaSocorroService unescape strings correctly`() { + val service = spy( + MozillaSocorroService( + testContext, + "Test App", + ), + ) + val test1 = "\\\\\\\\" + val expected1 = "\\" + assert(service.unescape(test1) == expected1) + + val test2 = "\\\\n" + val expected2 = "\n" + assert(service.unescape(test2) == expected2) + + val test3 = "\\\\t" + val expected3 = "\t" + assert(service.unescape(test3) == expected3) + + val test4 = "\\\\\\\\\\\\t\\\\t\\\\n\\\\\\\\" + val expected4 = "\\\t\t\n\\" + assert(service.unescape(test4) == expected4) + } + + @Test + fun `MozillaSocorroService returns crash id from Socorro`() { + val mockWebServer = MockWebServer() + + try { + mockWebServer.enqueue( + MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928"), + ) + mockWebServer.start() + + val service = MozillaSocorroService( + testContext, + "Test App", + "{1234-1234-1234}", + "0.1", + "1.0", + "Mozilla Test", + mockWebServer.url("/").toString(), + "0.0.1", + "123", + "test channel", + "test distribution id", + ) + + val crash = Crash.NativeCodeCrash( + 0, + "dump.path", + true, + "extras.path", + processType = Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + val id = service.report(crash) + + assertEquals("bp-924121d3-4de3-4b32-ab12-026fc0190928", id) + } finally { + mockWebServer.shutdown() + } + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt new file mode 100644 index 0000000000..e44dc64c9a --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt @@ -0,0 +1,169 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.content.ComponentName +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.robolectric.Robolectric + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class SendCrashReportServiceTest { + private var service: SendCrashReportService? = null + private val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Before + fun setUp() { + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService", + ) + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + ) + intent.putExtra("fatal", false) + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + ) + intent.putExtra("minidumpSuccess", true) + intent.putParcelableArrayListExtra("breadcrumbs", null) + service = spy(Robolectric.setupService(SendCrashReportService::class.java)) + service?.startService(intent) + } + + @After + fun tearDown() { + service?.stopService(intent) + CrashReporter.reset() + } + + @Test + fun `Send crash report will forward same crash to crash service`() { + var caughtCrash: Crash.NativeCodeCrash? = null + val crashReporter = spy( + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, + services = listOf( + object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + + override fun report(crash: Crash.UncaughtExceptionCrash): String? { + fail("Didn't expect uncaught exception crash") + return null + } + + override fun report(crash: Crash.NativeCodeCrash): String? { + caughtCrash = crash + return null + } + + override fun report(throwable: Throwable, breadcrumbs: ArrayList<Breadcrumb>): String? { + fail("Didn't expect caught exception") + return null + } + }, + ), + scope = scope, + notificationsDelegate = mock(), + ), + ).install(testContext) + val originalCrash = Crash.NativeCodeCrash( + 123, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + true, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService", + ) + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + ) + intent.putExtra("processType", "FOREGROUND_CHILD") + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + ) + intent.putExtra("minidumpSuccess", true) + intent.putParcelableArrayListExtra("breadcrumbs", null) + originalCrash.fillIn(intent) + + service?.onStartCommand(intent, 0, 0) + verify(crashReporter).submitReport(eq(originalCrash), any()) + assertNotNull(caughtCrash) + + val nativeCrash = caughtCrash + ?: throw AssertionError("Expected NativeCodeCrash instance") + + assertEquals(123, nativeCrash.timestamp) + assertEquals(true, nativeCrash.minidumpSuccess) + assertEquals(false, nativeCrash.isFatal) + assertEquals(Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, nativeCrash.processType) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + nativeCrash.minidumpPath, + ) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + nativeCrash.extrasPath, + ) + } + + @Test + fun `notification tag and id is added to the report intent`() { + val crash: Crash = Crash.NativeCodeCrash( + 123, + "", + true, + "", + Crash.NativeCodeCrash.PROCESS_TYPE_MAIN, + breadcrumbs = arrayListOf(), + remoteType = null, + ) + + val intent = SendCrashReportService.createReportIntent(testContext, crash, "test_tag", 123) + + assertEquals(intent.getStringExtra(NOTIFICATION_TAG_KEY), "test_tag") + assertEquals(intent.getIntExtra(NOTIFICATION_ID_KEY, 0), 123) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt new file mode 100644 index 0000000000..7aa7dcbe55 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.service + +import android.content.ComponentName +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.robolectric.Robolectric + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class SendCrashTelemetryServiceTest { + private var service: SendCrashTelemetryService? = null + private val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Before + fun setUp() { + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService", + ) + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + ) + intent.putExtra("fatal", false) + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + ) + intent.putExtra("minidumpSuccess", true) + intent.putParcelableArrayListExtra("breadcrumbs", null) + service = spy(Robolectric.setupService(SendCrashTelemetryService::class.java)) + service?.startService(intent) + } + + @After + fun tearDown() { + service?.stopService(intent) + CrashReporter.reset() + } + + @Test + fun `Send crash telemetry will forward same crash to crash telemetry service`() { + var caughtCrash: Crash.NativeCodeCrash? = null + val crashReporter = spy( + CrashReporter( + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, + telemetryServices = listOf( + object : CrashTelemetryService { + override fun record(crash: Crash.UncaughtExceptionCrash) { + fail("Didn't expect uncaught exception crash") + } + + override fun record(crash: Crash.NativeCodeCrash) { + caughtCrash = crash + } + + override fun record(throwable: Throwable) { + fail("Didn't expect caught exception") + } + }, + ), + scope = scope, + notificationsDelegate = mock(), + ), + ).install(testContext) + val originalCrash = Crash.NativeCodeCrash( + 123, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + true, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, + breadcrumbs = arrayListOf(), + remoteType = "null", + ) + + val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService", + ) + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + ) + intent.putExtra("processType", "FOREGROUND_CHILD") + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + ) + intent.putExtra("minidumpSuccess", true) + intent.putParcelableArrayListExtra("breadcrumbs", null) + originalCrash.fillIn(intent) + + service?.onStartCommand(intent, 0, 0) + + verify(crashReporter).submitCrashTelemetry(eq(originalCrash), any()) + assertNotNull(caughtCrash) + + val nativeCrash = caughtCrash + ?: throw AssertionError("Expected NativeCodeCrash instance") + + assertEquals(123, nativeCrash.timestamp) + assertEquals(true, nativeCrash.minidumpSuccess) + assertEquals(false, nativeCrash.isFatal) + assertEquals(Crash.NativeCodeCrash.PROCESS_TYPE_FOREGROUND_CHILD, nativeCrash.processType) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + nativeCrash.minidumpPath, + ) + assertEquals( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + nativeCrash.extrasPath, + ) + } +} diff --git a/mobile/android/android-components/components/lib/crash/src/test/resources/BadTestExtrasFile b/mobile/android/android-components/components/lib/crash/src/test/resources/BadTestExtrasFile new file mode 100755 index 0000000000..20098e30d8 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/resources/BadTestExtrasFile @@ -0,0 +1 @@ +{"ContentSandboxLevel":"2","TelemetryEnvironment":"{"EscapedField":"EscapedData\\n\\nfoo"}","EMCheckCompatibility":"true","ProductName":"Firefox","ContentSandboxCapabilities":"119","TelemetryClientId":"","Vendor":"Mozilla","InstallTime":"1000000000","Theme":"classic/1.0","ReleaseChannel":"default","ServerURL":"https://crash-reports.mozilla.com","SafeMode":"0","ContentSandboxCapable":"1","useragent_locale":"en-US","Version":"55.0a1","BuildID":"20170512114708","ProductID":"{ec8030f7-c20a-464f-9b0e-13a3a9e97384}","TelemetryServerURL":"","DOMIPCEnabled":"1","Add-ons":"","CrashTime":"1494582646","UptimeTS":"14.9179586","ThreadIdNameMapping":"","ContentSandboxEnabled":"1","ProcessType":"content","StartupTime":"1000000000","URL":"about:home"} diff --git a/mobile/android/android-components/components/lib/crash/src/test/resources/TestExtrasFile b/mobile/android/android-components/components/lib/crash/src/test/resources/TestExtrasFile new file mode 100755 index 0000000000..a95eb68ac3 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/resources/TestExtrasFile @@ -0,0 +1 @@ +{"ContentSandboxLevel":"2","TelemetryEnvironment":"{\"EscapedField\":\"EscapedData\\n\\nfoo\"}","EMCheckCompatibility":"true","ProductName":"Firefox","ContentSandboxCapabilities":"119","TelemetryClientId":"","Vendor":"Mozilla","InstallTime":"1000000000","Theme":"classic/1.0","ReleaseChannel":"default","ServerURL":"https://crash-reports.mozilla.com","SafeMode":"0","ContentSandboxCapable":"1","useragent_locale":"en-US","Version":"55.0a1","BuildID":"20170512114708","ProductID":"{ec8030f7-c20a-464f-9b0e-13a3a9e97384}","TelemetryServerURL":"","DOMIPCEnabled":"1","Add-ons":"","CrashTime":"1494582646","UptimeTS":"14.9179586","ThreadIdNameMapping":"","ContentSandboxEnabled":"1","ProcessType":"content","StartupTime":"1000000000","URL":"about:home","StackTraces":"test"} diff --git a/mobile/android/android-components/components/lib/crash/src/test/resources/TestLegacyExtrasFile b/mobile/android/android-components/components/lib/crash/src/test/resources/TestLegacyExtrasFile new file mode 100755 index 0000000000..7260d4d951 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/resources/TestLegacyExtrasFile @@ -0,0 +1,31 @@ +ContentSandboxLevel=2 +TelemetryEnvironment={"EscapedField":"EscapedData\\n\\nfoo"} +EMCheckCompatibility=true +ProductName=Firefox +ContentSandboxCapabilities=119 +TelemetryClientId= +Vendor=Mozilla +InstallTime=1000000000 +Theme=classic/1.0 +ReleaseChannel=default +ServerURL=https://crash-reports.mozilla.com +SafeMode=0 +ContentSandboxCapable=1 +useragent_locale=en-US +Version=55.0a1 +BuildID=20170512114708 +ProductID={ec8030f7-c20a-464f-9b0e-13a3a9e97384} +TelemetryServerURL= +DOMIPCEnabled=1 +Add-ons= +CrashTime=1494582646 +UptimeTS=14.9179586 +ThreadIdNameMapping= +ContentSandboxLevel=2 +ContentSandboxEnabled=1 +ProcessType=content +DOMIPCEnabled=1 +StartupTime=1000000000 +URL=about:home +ContentSandboxCapabilities=119 +StackTraces=test
\ No newline at end of file diff --git a/mobile/android/android-components/components/lib/crash/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/lib/crash/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/lib/crash/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/crash/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/crash/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/dataprotect/README.md b/mobile/android/android-components/components/lib/dataprotect/README.md new file mode 100644 index 0000000000..b9ca9068a4 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/README.md @@ -0,0 +1,19 @@ +# [Android Components](../../../README.md) > Libraries > Dataprotect + +A component using AndroidKeyStore to protect user data. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-dataprotect:{latest-version}" +``` + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/dataprotect/build.gradle b/mobile/android/android-components/components/lib/dataprotect/build.gradle new file mode 100644 index 0000000000..200ee8b2e0 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/build.gradle @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.dataprotect' +} + +dependencies { + implementation project(':support-base') + + implementation ComponentsDependencies.androidx_annotation + + testImplementation project(':support-test') + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/dataprotect/proguard-rules.pro b/mobile/android/android-components/components/lib/dataprotect/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/dataprotect/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/dataprotect/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..41078a7325 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/Keystore.kt b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/Keystore.kt new file mode 100644 index 0000000000..8b0b09f2c2 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/Keystore.kt @@ -0,0 +1,314 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import android.annotation.TargetApi +import android.os.Build.VERSION_CODES.M +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import mozilla.components.support.base.log.logger.Logger +import java.security.GeneralSecurityException +import java.security.InvalidKeyException +import java.security.Key +import java.security.KeyStore +import java.security.UnrecoverableKeyException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private const val KEYSTORE_TYPE = "AndroidKeyStore" +private const val ENCRYPTED_VERSION = 0x02 + +@TargetApi(M) +internal const val CIPHER_ALG = KeyProperties.KEY_ALGORITHM_AES + +@TargetApi(M) +internal const val CIPHER_MOD = KeyProperties.BLOCK_MODE_GCM + +@TargetApi(M) +internal const val CIPHER_PAD = KeyProperties.ENCRYPTION_PADDING_NONE +internal const val CIPHER_KEY_LEN = 256 +internal const val CIPHER_TAG_LEN = 128 +internal const val CIPHER_SPEC = "$CIPHER_ALG/$CIPHER_MOD/$CIPHER_PAD" + +internal const val CIPHER_NONCE_LEN = 12 + +/** + * Wraps the critical functions around a Java KeyStore to better facilitate testing + * and instrumenting. + * + */ +@TargetApi(M) +open class KeyStoreWrapper { + private var keystore: KeyStore? = null + private val logger = Logger("KeyStoreWrapper") + + /** + * Retrieves the underlying KeyStore, loading it if necessary. + */ + fun getKeyStore(): KeyStore { + var ks = keystore + if (ks == null) { + ks = loadKeyStore() + keystore = ks + } + + return ks + } + + /** + * Retrieves the SecretKey for the given label. + * + * This method queries for a SecretKey with the given label and no passphrase. + * + * Subclasses override this method if additional properties are needed + * to retrieve the key. + * + * @param label The label to query + * @return The key for the given label, or `null` if not present + * @throws InvalidKeyException If there is a Key but it is not a SecretKey + * @throws NoSuchAlgorithmException If the recovery algorithm is not supported + */ + open fun getKeyFor(label: String): Key? = try { + loadKeyStore().getKey(label, null) + } catch (e: UnrecoverableKeyException) { + logger.error("Failed to get key", e) + null + } + + /** + * Creates a SecretKey for the given label. + * + * This method generates a SecretKey pre-bound to the `AndroidKeyStore` and configured + * with the strongest "algorithm/blockmode/padding" (and key size) available. + * + * Subclasses override this method to properly associate the generated key with + * the given label in the underlying KeyStore. + * + * @param label The label to associate with the created key + * @return The newly-generated key for `label` + * @throws NoSuchAlgorithmException If the cipher algorithm is not supported + */ + open fun makeKeyFor(label: String): SecretKey { + val spec = KeyGenParameterSpec.Builder( + label, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(CIPHER_KEY_LEN) + .setBlockModes(CIPHER_MOD) + .setEncryptionPaddings(CIPHER_PAD) + .build() + val gen = KeyGenerator.getInstance(CIPHER_ALG, KEYSTORE_TYPE) + gen.init(spec) + return gen.generateKey() + } + + /** + * Deletes a key with the given label. + * + * @param label The label of the associated key to delete + * @throws KeyStoreException If there is no key for `label` + */ + fun removeKeyFor(label: String) { + getKeyStore().deleteEntry(label) + } + + /** + * Creates and initializes the KeyStore in use. + * + * This method loads a`"AndroidKeyStore"` type KeyStore. + * + * Subclasses override this to load a KeyStore appropriate to the testing environment. + * + * @return The KeyStore, already initialized + * @throws KeyStoreException if the type of store is not supported + */ + open fun loadKeyStore(): KeyStore { + val ks = KeyStore.getInstance(KEYSTORE_TYPE) + ks.load(null) + return ks + } +} + +/** + * Manages data protection using a system-isolated cryptographic key. + * + * This class provides for both: + * * management for a specific crypto graphic key (identified by a string label) + * * protection (encryption/decryption) of data using the managed key + * + * The specific cryptographic properties are pre-chosen to be the following: + * * Algorithm is "AES/GCM/NoPadding" + * * Key size is 256 bits + * * Tag size is 128 bits + * + * @property label The label the cryptographic key is identified as + * @constructor Creates a new instance around a key identified by the given label + * + * Unless `manual` is `true`, the key is created if not already present in the + * platform's key storage. + */ +@TargetApi(M) +open class Keystore( + val label: String, + manual: Boolean = false, + internal val wrapper: KeyStoreWrapper = KeyStoreWrapper(), +) { + init { + if (!manual and !available()) { + generateKey() + } + } + + private fun getKey(): SecretKey? = + wrapper.getKeyFor(label) as? SecretKey? + + /** + * Determines if the managed key is available for use. Consumers can use this to + * determine if the key was somehow lost and should treat any previously-protected + * data as invalid. + * + * @return `true` if the managed key exists and ready for use. + */ + fun available(): Boolean = (getKey() != null) + + /** + * Generates the managed key if it does not already exist. + * + * @return `true` if a new key was generated; `false` if the key already exists and can + * be used. + * @throws GeneralSecurityException If the key could not be created + */ + @Throws(GeneralSecurityException::class) + fun generateKey(): Boolean { + val key = wrapper.getKeyFor(label) + if (key != null) { + when (key) { + is SecretKey -> return false + else -> throw InvalidKeyException("unsupported key type") + } + } + + wrapper.makeKeyFor(label) + + return true + } + + /** + * Deletes the managed key. + * + * **NOTE:** Once this method returns, any data protected with the (formerly) managed + * key cannot be decrypted and therefore is inaccessble. + */ + fun deleteKey() { + val key = wrapper.getKeyFor(label) + if (key != null) { + wrapper.removeKeyFor(label) + } + } + + /** + * Encrypts data using the managed key. + * + * The output of this method includes the input factors (i.e., initialization vector), + * ciphertext, and authentication tag as a single byte string; this output can be passed + * directly to [decryptBytes]. + * + * @param plain The "plaintext" data to encrypt + * @return The encrypted data to be stored + * @throws GeneralSecurityException If the data could not be encrypted + */ + @Throws(GeneralSecurityException::class) + open fun encryptBytes(plain: ByteArray): ByteArray { + // 5116-style interface = [ inputs || ciphertext || atag ] + // - inputs = [ version = 0x02 || cipher.iv (always 12 bytes) ] + // - cipher.doFinal() provides [ ciphertext || atag ] + // Cipher operations are not thread-safe so we synchronize over them through doFinal to + // prevent crashes with quickly repeated encrypt/decrypt operations + // https://github.com/mozilla-mobile/android-components/issues/5342 + synchronized(this) { + val cipher = createEncryptCipher() + val cdata = cipher.doFinal(plain) + val nonce = cipher.iv + + return byteArrayOf(ENCRYPTED_VERSION.toByte()) + nonce + cdata + } + } + + /** + * Decrypts data using the managed key. + * + * The input of this method is expected to include input factors (i.e., initialization + * vector), ciphertext, and authentication tag as a single byte string; it is the direct + * output from [encryptBytes]. + * + * @param encrypted The encrypted data to decrypt + * @return The decrypted "plaintext" data + * @throws KeystoreException If the data could not be decrypted + */ + @Throws(KeystoreException::class) + open fun decryptBytes(encrypted: ByteArray): ByteArray { + val version = encrypted[0].toInt() + if (version != ENCRYPTED_VERSION) { + throw KeystoreException("unsupported encrypted version: $version") + } + + // Cipher operations are not thread-safe so we synchronize over them through doFinal to + // prevent crashes with quickly repeated encrypt/decrypt operations + // https://github.com/mozilla-mobile/android-components/issues/5342 + synchronized(this) { + val iv = encrypted.sliceArray(1..CIPHER_NONCE_LEN) + val cdata = encrypted.sliceArray((CIPHER_NONCE_LEN + 1)..encrypted.size - 1) + val cipher = createDecryptCipher(iv) + return cipher.doFinal(cdata) + } + } + + /** + * Create a cipher initialized for encrypting data with the managed key. + * + * This "low-level" method is useful when a cryptographic context is needed to integrate with + * other APIs, such as the `FingerprintManager`. + * + * **NOTE:** The caller is responsible for associating certain encryption factors, such as + * the initialization vector and/or additional authentication data (AAD), with the resulting + * ciphertext or decryption will fail. + * + * @return The [Cipher], initialized and ready to encrypt data with. + * @throws GeneralSecurityException If the Cipher could not be created and initialized + */ + @Throws(GeneralSecurityException::class) + open fun createEncryptCipher(): Cipher { + val key = getKey() ?: throw InvalidKeyException("unknown label: $label") + val cipher = Cipher.getInstance(CIPHER_SPEC) + cipher.init(Cipher.ENCRYPT_MODE, key) + + return cipher + } + + /** + * Create a cipher initialized for decrypting data with the managed key. + * + * This "low-level" method is useful when a cryptographic context is needed to integrate with + * other APIs, such as the `FingerprintManager`. + * + * **NOTE:** The caller is responsible for associating certain encryption factors, such as + * the initialization vector and/or additional authentication data (AAD), with the stored + * ciphertext or decryption will fail. + * + * @param iv The initialization vector/nonce to decrypt with + * @return The [Cipher], initialized and ready to decrypt data with. + * @throws GeneralSecurityException If the cipher could not be created and initialized + */ + @Throws(GeneralSecurityException::class) + open fun createDecryptCipher(iv: ByteArray): Cipher { + val key = getKey() ?: throw InvalidKeyException("unknown label: $label") + val cipher = Cipher.getInstance(CIPHER_SPEC) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(CIPHER_TAG_LEN, iv)) + + return cipher + } +} diff --git a/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/KeystoreException.kt b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/KeystoreException.kt new file mode 100644 index 0000000000..3b95d4bc32 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/KeystoreException.kt @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import java.security.GeneralSecurityException + +/** + * Exception type thrown by {@link Keystore} when an error is encountered that + * is not otherwise covered by an existing sub-class to `GeneralSecurityException`. + * + */ +class KeystoreException( + message: String? = null, + cause: Throwable? = null, +) : GeneralSecurityException(message, cause) diff --git a/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/SecureAbove22Preferences.kt b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/SecureAbove22Preferences.kt new file mode 100644 index 0000000000..28ae337df9 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/SecureAbove22Preferences.kt @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import android.annotation.TargetApi +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import android.os.Build +import android.os.Build.VERSION_CODES.M +import android.util.Base64 +import mozilla.components.support.base.log.logger.Logger +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException + +private interface KeyValuePreferences { + /** + * Retrieves all key/value pairs present in the store. + * + * @return A [Map] containing all key/value pairs present in the store. + */ + fun all(): Map<String, String> + + /** + * Retrieves a stored [key]. See [putString] for storing a [key]. + * + * @param key A key name. + * @return An optional [String] if [key] is present in the store. + */ + fun getString(key: String): String? + + /** + * Stores [value] under [key]. Retrieve it using [getString]. + * + * @param key A key name. + * @param value A value for [key]. + */ + fun putString(key: String, value: String) + + /** + * Removes key/value pair from storage for the provided [key]. + */ + fun remove(key: String) + + /** + * Clears all key/value pairs from the storage. + */ + fun clear() +} + +/** + * A wrapper around [SharedPreferences] which encrypts contents on supported API versions (23+). + * Otherwise, this simply delegates to [SharedPreferences]. + * + * In rare circumstances (such as APK signing key rotation) a master key which protects this storage may be lost, + * in which case previously stored values will be lost as well. Applications are encouraged to instrument such events. + * + * @param context A [Context], used for accessing [SharedPreferences]. + * @param name A name for this storage, used for isolating different instances of [SecureAbove22Preferences]. + * @param forceInsecure A flag indicating whether to force plaintext storage. If set to `true`, + * [InsecurePreferencesImpl21] will be used as a storage layer, otherwise a storage implementation + * will be decided based on Android API version, with a preference given to secure storage + */ +class SecureAbove22Preferences(context: Context, name: String, forceInsecure: Boolean = false) : + KeyValuePreferences { + private val impl = if (Build.VERSION.SDK_INT >= M && !forceInsecure) { + SecurePreferencesImpl23(context, name) + } else { + InsecurePreferencesImpl21(context, name) + } + + override fun all(): Map<String, String> = impl.all() + + override fun getString(key: String) = impl.getString(key) + + override fun putString(key: String, value: String) = impl.putString(key, value) + + override fun remove(key: String) = impl.remove(key) + + override fun clear() = impl.clear() +} + +/** + * A simple [KeyValuePreferences] implementation which entirely delegates to [SharedPreferences] and doesn't perform any + * encryption/decryption. + */ +@SuppressWarnings("TooGenericExceptionCaught") +private class InsecurePreferencesImpl21( + context: Context, + name: String, + migrateFromSecureStorage: Boolean = true, +) : KeyValuePreferences { + companion object { + private const val SUFFIX = "_kp_pre_m" + } + + internal val logger = Logger("mozac/InsecurePreferencesImpl21") + + private val prefs = context.getSharedPreferences("$name$SUFFIX", MODE_PRIVATE) + + init { + // Check if we have any encrypted values stored on disk. + if (migrateFromSecureStorage && Build.VERSION.SDK_INT >= M && prefs.all.isEmpty()) { + val secureStorage = SecurePreferencesImpl23(context, name, false) + // Copy over any old values. + try { + secureStorage.all().forEach { + putString(it.key, it.value) + } + } catch (e: Exception) { + // Certain devices crash on various Keystore exceptions. While trying to migrate + // to use the plaintext storage we don't want to crash if we can't access secure + // storage, and just catch the errors. + logger.error("Migrating from secure storage failed", e) + } + // Erase old storage. + secureStorage.clear() + } + } + + override fun all(): Map<String, String> { + return prefs.all.mapNotNull { + if (it.value is String) { + it.key to it.value as String + } else { + null + } + }.toMap() + } + + override fun getString(key: String) = prefs.getString(key, null) + + override fun putString(key: String, value: String) = prefs.edit().putString(key, value).apply() + + override fun remove(key: String) = prefs.edit().remove(key).apply() + + override fun clear() = prefs.edit().clear().apply() +} + +/** + * A [KeyValuePreferences] which is backed by [SharedPreferences] and performs encryption/decryption of values. + */ +@TargetApi(M) +private class SecurePreferencesImpl23( + context: Context, + name: String, + migrateFromPlaintextStorage: Boolean = true, +) : KeyValuePreferences { + companion object { + private const val SUFFIX = "_kp_post_m" + private const val BASE_64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING + } + + private val logger = Logger("SecurePreferencesImpl23") + private val prefs = context.getSharedPreferences("$name$SUFFIX", MODE_PRIVATE) + private val keystore by lazy { Keystore(context.packageName) } + + init { + if (migrateFromPlaintextStorage && prefs.all.isEmpty()) { + // Check if we have any plaintext values stored on disk. That indicates that we've hit + // an API upgrade situation. We just went from pre-M to post-M. Since we already have + // the plaintext keys, we can transparently migrate them to use the encrypted storage layer. + val insecureStorage = InsecurePreferencesImpl21(context, name, false) + // Copy over any old values. + insecureStorage.all().forEach { + putString(it.key, it.value) + } + // Erase old storage. + insecureStorage.clear() + } + } + + override fun all(): Map<String, String> { + return prefs.all.keys.mapNotNull { key -> + getString(key)?.let { value -> + key to value + } + }.toMap() + } + + override fun getString(key: String): String? { + // The fact that we're possibly generating a managed key here implies that this key could be lost after being + // for some reason. One possible reason for a key to be lost is rotating signing keys for the APK. + // Applications are encouraged to instrument such events. + generateManagedKeyIfNecessary() + + if (!prefs.contains(key)) { + return null + } + + val value = prefs.getString(key, "") + val encrypted = Base64.decode(value, BASE_64_FLAGS) + + return try { + String(keystore.decryptBytes(encrypted), StandardCharsets.UTF_8) + } catch (error: IllegalArgumentException) { + logger.error("IllegalArgumentException exception: ", error) + null + } catch (error: GeneralSecurityException) { + logger.error("Decrypt exception: ", error) + null + } + } + + override fun putString(key: String, value: String) { + generateManagedKeyIfNecessary() + val editor = prefs.edit() + + val encrypted = keystore.encryptBytes(value.toByteArray(StandardCharsets.UTF_8)) + val data = Base64.encodeToString(encrypted, BASE_64_FLAGS) + + editor.putString(key, data).apply() + } + + override fun remove(key: String) = prefs.edit().remove(key).apply() + + override fun clear() = prefs.edit().clear().apply() + + /** + * Generates a "managed key" - a key used to encrypt data stored by this class. This key is "managed" by [Keystore], + * which stores it in system's secure storage layer exposed via [AndroidKeyStore]. + */ + private fun generateManagedKeyIfNecessary() { + // Do we need to check this on every access, or just during instantiation? Is the overhead here worth it? + if (!keystore.available()) { + keystore.generateKey() + } + } +} diff --git a/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/SecurePrefsReliabilityExperiment.kt b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/SecurePrefsReliabilityExperiment.kt new file mode 100644 index 0000000000..8ca545eb3b --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/main/java/mozilla/components/lib/dataprotect/SecurePrefsReliabilityExperiment.kt @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import android.content.Context +import android.content.SharedPreferences +import mozilla.components.support.base.Component +import mozilla.components.support.base.facts.Action +import mozilla.components.support.base.facts.Fact +import mozilla.components.support.base.facts.collect +import java.lang.Exception + +/** + * This class exists so that we can measure how reliable our usage of AndroidKeyStore is. + * + * All of the actions here are executed against SecureAbove22Preferences, which encrypts/decrypts prefs + * using a key managed by AndroidKeyStore. + * If device is running on API<23, encryption/decryption won't be used. + * + * Experiment actions are: + * - on every invocation, read a persisted value and verify it's correct; if it's missing write it. + * - if an error is encountered (e.g. corrupt/missing value), experiment state is reset and the + * experiment starts from scratch. + * + * For each step (get, write, reset), a Fact is emitted describing what happened (success, type of failure). + * A special "experiment" Fact will be emitted in case of an unexpected failure. + * + * Consumers of this experiment are expected to inspect emitted Facts (e.g. record them into telemetry). + */ +class SecurePrefsReliabilityExperiment(private val context: Context) { + companion object { + const val PREFS_NAME = "KsReliabilityExp" + const val PREF_DID_STORE_VALUE = "valueStored" + const val SECURE_PREFS_NAME = "KsReliabilityExpSecure" + const val PREF_KEY = "expKey" + const val PREF_VALUE = "some long, mildly interesting string we'd like to store" + + object Actions { + const val EXPERIMENT = "experiment" + const val GET = "get" + const val WRITE = "write" + const val RESET = "reset" + } + + @Suppress("MagicNumber") + enum class Values(val v: Int) { + SUCCESS_MISSING(1), + SUCCESS_PRESENT(2), + FAIL(3), + LOST(4), + CORRUPTED(5), + PRESENT_UNEXPECTED(6), + SUCCESS_WRITE(7), + SUCCESS_RESET(8), + } + } + + private val securePrefs by lazy { SecureAbove22Preferences(context, SECURE_PREFS_NAME) } + + private fun prefs(): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + /** + * Runs an experiment. This will emit one or more [Fact]s describing results. + */ + @Suppress("TooGenericExceptionCaught", "ComplexMethod") + operator fun invoke() { + try { + val storedVal = try { + securePrefs.getString(PREF_KEY) + } catch (e: Exception) { + emitFact(Actions.GET, Values.FAIL, mapOf("javaClass" to e.nameForTelemetry())) + + // should this return? or proceed to the write part..? + return + } + + val valueAlreadyPersisted = prefs().getBoolean(PREF_DID_STORE_VALUE, false) + + val getResult = when { + // we didn't store the value yet, and didn't get anything back either + (!valueAlreadyPersisted && storedVal == null) -> { + Values.SUCCESS_MISSING + } + // we got back the value we stored + (valueAlreadyPersisted && storedVal == PREF_VALUE) -> { + Values.SUCCESS_PRESENT + } + // value was lost + (valueAlreadyPersisted && storedVal == null) -> { + Values.LOST + } + // we got some value back, but not what we stored + (valueAlreadyPersisted && storedVal != PREF_VALUE) -> { + Values.CORRUPTED + } + // we didn't store the value yet, but got something back either way + else -> { + Values.PRESENT_UNEXPECTED + } + } + + emitFact(Actions.GET, getResult) + + when (getResult) { + // perform a write of the missing value + Values.SUCCESS_MISSING -> { + try { + securePrefs.putString(PREF_KEY, PREF_VALUE) + emitFact(Actions.WRITE, Values.SUCCESS_WRITE) + } catch (e: Exception) { + emitFact(Actions.WRITE, Values.FAIL, mapOf("javaClass" to e.nameForTelemetry())) + } + prefs().edit().putBoolean(PREF_DID_STORE_VALUE, true).apply() + } + // reset our experiment in case of detected failures. this lets us measure the failure rate + Values.LOST, Values.CORRUPTED, Values.PRESENT_UNEXPECTED -> { + securePrefs.clear() + prefs().edit().clear().apply() + emitFact(Actions.RESET, Values.SUCCESS_RESET) + } + else -> { + // no-op + } + } + } catch (e: Exception) { + emitFact(Actions.EXPERIMENT, Values.FAIL, mapOf("javaClass" to e.nameForTelemetry())) + } + } +} + +private fun emitFact( + item: String, + value: SecurePrefsReliabilityExperiment.Companion.Values, + metadata: Map<String, Any>? = null, +) { + Fact( + Component.LIB_DATAPROTECT, + Action.IMPLEMENTATION_DETAIL, + item, + "${value.v}", + metadata, + ).collect() +} + +private fun Exception.nameForTelemetry(): String { + return this.javaClass.canonicalName ?: "anonymous" +} diff --git a/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/KeystoreTest.kt b/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/KeystoreTest.kt new file mode 100644 index 0000000000..ce11b43ad3 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/KeystoreTest.kt @@ -0,0 +1,137 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import org.junit.Assert +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException +import java.security.Key +import java.security.KeyStore +import java.security.SecureRandom +import java.security.Security +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +private val DEFAULTPASS = "testit!".toCharArray() + +/* mock keystore wrapper to deal with intricacies of how Java/Anroid key management work */ +internal class MockStoreWrapper : KeyStoreWrapper() { + override fun loadKeyStore(): KeyStore { + val ks = KeyStore.getInstance("JCEKS") + ks.load(null) + return ks + } + + override fun getKeyFor(label: String): Key? = + getKeyStore().getKey(label, DEFAULTPASS) + override fun makeKeyFor(label: String): SecretKey { + val gen = KeyGenerator.getInstance("AES") + gen.init(256) + val key = gen.generateKey() + getKeyStore().setKeyEntry(label, key, DEFAULTPASS, null) + + return key + } +} + +class KeystoreTest { + + private var wrapper = MockStoreWrapper() + private var rng = SecureRandom() + + @Before + fun setUp() { + Security.setProperty("crypto.policy", "unlimited") + } + + @Test + fun workingWithLabel() { + val keystore = Keystore("test-labels", true, wrapper) + + Assert.assertFalse(keystore.available()) + keystore.generateKey() + Assert.assertTrue(keystore.available()) + keystore.deleteKey() + Assert.assertFalse(keystore.available()) + } + + @Test + fun createEncryptCipher() { + val keystore = Keystore("test-encrypt-ciphers", true, wrapper) + + Assert.assertFalse(keystore.available()) + var caught = false + var cipher: Cipher? = null + try { + cipher = keystore.createEncryptCipher() + } catch (ex: GeneralSecurityException) { + caught = true + } finally { + Assert.assertTrue("unexpected success", caught) + Assert.assertNull(cipher) + } + + keystore.generateKey() + Assert.assertTrue(keystore.available()) + cipher = keystore.createEncryptCipher() + Assert.assertEquals(CIPHER_SPEC, cipher.algorithm) + Assert.assertNotNull(cipher.iv) + } + + @Test + fun createDecryptCipher() { + val keystore = Keystore("test-decrypt-ciphers", true, wrapper) + val iv = ByteArray(12) + rng.nextBytes(iv) + + Assert.assertFalse(keystore.available()) + var caught = false + var cipher: Cipher? = null + try { + cipher = keystore.createDecryptCipher(iv) + } catch (ex: GeneralSecurityException) { + caught = true + } finally { + Assert.assertTrue("unexpected success", caught) + Assert.assertNull(cipher) + } + + keystore.generateKey() + Assert.assertTrue(keystore.available()) + cipher = keystore.createDecryptCipher(iv) + Assert.assertEquals(CIPHER_SPEC, cipher.algorithm) + Assert.assertArrayEquals(iv, cipher.iv) + } + + @Test + fun testAutoInit() { + val keystore = Keystore("test-auto-init", false, wrapper) + + Assert.assertTrue(keystore.available()) + Assert.assertFalse(keystore.generateKey()) + + var cipher: Cipher? + cipher = keystore.createEncryptCipher() + Assert.assertNotNull(cipher) + cipher = keystore.createDecryptCipher(ByteArray(12)) + Assert.assertNotNull(cipher) + } + + @Ignore("https://github.com/mozilla-mobile/android-components/issues/4956") + @Test + fun cryptoRoundTrip() { + val keystore = Keystore("test-roundtrip", wrapper = wrapper) + + var input = "classic plaintext 'hello, world'".toByteArray(StandardCharsets.UTF_8) + var encrypted = keystore.encryptBytes(input) + Assert.assertNotNull(encrypted) + var output = keystore.decryptBytes(encrypted) + Assert.assertArrayEquals(input, output) + } +} diff --git a/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/SecureAbove22PreferencesTest.kt b/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/SecureAbove22PreferencesTest.kt new file mode 100644 index 0000000000..2a8a87308d --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/SecureAbove22PreferencesTest.kt @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.security.Security + +@RunWith(AndroidJUnit4::class) +class SecureAbove22PreferencesTest { + @Config(sdk = [21]) + @Test + fun `CRUD tests API level 21 unencrypted`() { + val storage = SecureAbove22Preferences(testContext, "hello") + val storage2 = SecureAbove22Preferences(testContext, "world") + + // no keys + assertNull(storage.getString("hello")) + assertTrue(storage2.all().isEmpty()) + + // single key + storage.putString("hello", "world") + assertEquals("world", storage.getString("hello")) + assertTrue(storage2.all().isEmpty()) + + // single key, updated + storage.putString("hello", "you") + assertEquals("you", storage.getString("hello")) + assertTrue(storage2.all().isEmpty()) + + // multiple keys + storage.putString("test", "string") + assertEquals("string", storage.getString("test")) + assertEquals("you", storage.getString("hello")) + val all = storage.all() + assertEquals(2, all.size) + assertEquals("string", all["test"]) + assertEquals("you", all["hello"]) + assertTrue(storage2.all().isEmpty()) + + // clearing one storage doesn't affect another with a different name + storage2.putString("another", "test") + assertEquals(1, storage2.all().size) + storage2.clear() + assertEquals(2, storage.all().size) + + // key removal + storage.remove("hello") + assertNull(storage.getString("hello")) + storage.remove("test") + assertNull(storage.getString("test")) + assertTrue(storage2.all().isEmpty()) + + // clearing + storage.putString("one", "two") + assertEquals("two", storage.getString("one")) + storage.putString("three", "four") + assertEquals("four", storage.getString("three")) + storage.putString("five", "six") + assertEquals("six", storage.getString("five")) + assertTrue(storage2.all().isEmpty()) + + storage.clear() + assertNull(storage.getString("one")) + assertNull(storage.getString("three")) + assertNull(storage.getString("five")) + assertTrue(storage.all().isEmpty()) + assertTrue(storage2.all().isEmpty()) + } + + @Config(sdk = [22]) + @Test + fun `CRUD tests API level 22 unencrypted`() { + val storage = SecureAbove22Preferences(testContext, "hello") + val storage2 = SecureAbove22Preferences(testContext, "world") + + // no keys + assertNull(storage.getString("hello")) + assertTrue(storage2.all().isEmpty()) + + // single key + storage.putString("hello", "world") + assertEquals("world", storage.getString("hello")) + assertTrue(storage2.all().isEmpty()) + + // single key, updated + storage.putString("hello", "you") + assertEquals("you", storage.getString("hello")) + assertTrue(storage2.all().isEmpty()) + + // multiple keys + storage.putString("test", "string") + assertEquals("string", storage.getString("test")) + assertEquals("you", storage.getString("hello")) + val all = storage.all() + assertEquals(2, all.size) + assertEquals("string", all["test"]) + assertEquals("you", all["hello"]) + assertTrue(storage2.all().isEmpty()) + + // clearing one storage doesn't affect another with a different name + storage2.putString("another", "test") + assertEquals(1, storage2.all().size) + storage2.clear() + assertEquals(2, storage.all().size) + + // key removal + storage.remove("hello") + assertNull(storage.getString("hello")) + storage.remove("test") + assertNull(storage.getString("test")) + assertTrue(storage2.all().isEmpty()) + + // clearing + storage.putString("one", "two") + assertEquals("two", storage.getString("one")) + storage.putString("three", "four") + assertEquals("four", storage.getString("three")) + storage.putString("five", "six") + assertEquals("six", storage.getString("five")) + assertTrue(storage2.all().isEmpty()) + + storage.clear() + assertNull(storage.getString("one")) + assertNull(storage.getString("three")) + assertNull(storage.getString("five")) + assertTrue(storage.all().isEmpty()) + assertTrue(storage2.all().isEmpty()) + } + + @Config(sdk = [21]) + @Test + fun `storage instances of the same name are interchangeable`() { + val storage = SecureAbove22Preferences(testContext, "hello") + val storage2 = SecureAbove22Preferences(testContext, "hello") + + storage.putString("key1", "value1") + assertEquals("value1", storage2.getString("key1")) + + storage2.putString("something", "other") + assertEquals("other", storage.getString("something")) + + assertEquals(storage.all().size, storage2.all().size) + assertEquals(storage.all(), storage2.all()) + + storage.clear() + assertTrue(storage2.all().isEmpty()) + } + + @Ignore("https://github.com/mozilla-mobile/android-components/issues/4956") + @Config(sdk = [23]) + @Test + fun `CRUD tests API level 23+ encrypted`() { + // TODO find out what this is; lockwise tests set it. + Security.setProperty("crypto.policy", "unlimited") + + val storage = SecureAbove22Preferences(testContext, "test") + + // no keys + assertNull(storage.getString("hello")) + + // single key + storage.putString("hello", "world") + assertEquals("world", storage.getString("hello")) + + // single key, updated + storage.putString("hello", "you") + assertEquals("you", storage.getString("hello")) + + // multiple keys + storage.putString("test", "string") + assertEquals("string", storage.getString("test")) + assertEquals("you", storage.getString("hello")) + + // key removal + storage.remove("hello") + assertNull(storage.getString("hello")) + storage.remove("test") + assertNull(storage.getString("test")) + } +} diff --git a/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/SecurePrefsReliabilityExperimentTest.kt b/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/SecurePrefsReliabilityExperimentTest.kt new file mode 100644 index 0000000000..3cd0497dd3 --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/test/java/mozilla/components/lib/dataprotect/SecurePrefsReliabilityExperimentTest.kt @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.dataprotect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.lib.dataprotect.SecurePrefsReliabilityExperiment.Companion.Actions +import mozilla.components.lib.dataprotect.SecurePrefsReliabilityExperiment.Companion.Values +import mozilla.components.support.base.Component +import mozilla.components.support.base.facts.Action +import mozilla.components.support.base.facts.Fact +import mozilla.components.support.base.facts.FactProcessor +import mozilla.components.support.base.facts.Facts +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.reset +import org.mockito.Mockito.times +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class SecurePrefsReliabilityExperimentTest { + @Config(sdk = [21]) + @Test + fun `working first run and rerurns emit correct facts`() { + val processor: FactProcessor = mock() + + Facts.registerProcessor(processor) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_MISSING, + Actions.WRITE to Values.SUCCESS_WRITE, + ) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_PRESENT, + ) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_PRESENT, + ) + } + + @Config(sdk = [21]) + @Test + fun `corrupt value returned`() { + val processor: FactProcessor = mock() + + Facts.registerProcessor(processor) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_MISSING, + Actions.WRITE to Values.SUCCESS_WRITE, + ) + + // Now, let's corrupt the value manually + val securePrefs = SecureAbove22Preferences( + testContext, + SecurePrefsReliabilityExperiment.SECURE_PREFS_NAME, + ) + securePrefs.putString(SecurePrefsReliabilityExperiment.PREF_KEY, "wrong test string") + + triggerAndAssertFacts( + processor, + Actions.GET to Values.CORRUPTED, + Actions.RESET to Values.SUCCESS_RESET, + ) + + // ... and we should be reset now: + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_MISSING, + Actions.WRITE to Values.SUCCESS_WRITE, + ) + } + + @Config(sdk = [21]) + @Test + fun `lost value`() { + val processor: FactProcessor = mock() + + Facts.registerProcessor(processor) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_MISSING, + Actions.WRITE to Values.SUCCESS_WRITE, + ) + + // Now, let's corrupt the store manually + val securePrefs = SecureAbove22Preferences( + testContext, + SecurePrefsReliabilityExperiment.SECURE_PREFS_NAME, + ) + securePrefs.clear() + + // loss is detected: + triggerAndAssertFacts( + processor, + Actions.GET to Values.LOST, + Actions.RESET to Values.SUCCESS_RESET, + ) + + // we should be reset now: + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_MISSING, + Actions.WRITE to Values.SUCCESS_WRITE, + ) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_PRESENT, + ) + } + + @Config(sdk = [21]) + @Test + fun `value present unexpectedly`() { + val processor: FactProcessor = mock() + + Facts.registerProcessor(processor) + + // First, let's add the correct value manually: + val securePrefs = SecureAbove22Preferences( + testContext, + SecurePrefsReliabilityExperiment.SECURE_PREFS_NAME, + ) + securePrefs.putString(SecurePrefsReliabilityExperiment.PREF_KEY, SecurePrefsReliabilityExperiment.PREF_VALUE) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.PRESENT_UNEXPECTED, + Actions.RESET to Values.SUCCESS_RESET, + ) + + // Let's try an incorrect value, as well: + securePrefs.putString(SecurePrefsReliabilityExperiment.PREF_KEY, "bad string") + + triggerAndAssertFacts( + processor, + Actions.GET to Values.PRESENT_UNEXPECTED, + Actions.RESET to Values.SUCCESS_RESET, + ) + + // subsequently, it's all good: + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_MISSING, + Actions.WRITE to Values.SUCCESS_WRITE, + ) + + triggerAndAssertFacts( + processor, + Actions.GET to Values.SUCCESS_PRESENT, + ) + } + + @Test + fun `initialization failure`() { + // AndroidKeyStore isn't available in the test environment. + // This test runs against our target sdk version, so the experiment code will attempt to init + // the AndroidKeyStore, that won't be available. + val processor: FactProcessor = mock() + + Facts.registerProcessor(processor) + + SecurePrefsReliabilityExperiment(testContext)() + + val captor = argumentCaptor<Fact>() + Mockito.verify(processor).process(captor.capture()) + + assertEquals(1, captor.allValues.size) + assertExperimentFact( + captor.allValues[0], + Actions.GET, + Values.FAIL, + mapOf("javaClass" to "java.security.KeyStoreException"), + ) + } + + private fun triggerAndAssertFacts(processor: FactProcessor, vararg factPairs: Pair<String, Values>) { + with(argumentCaptor<Fact>()) { + SecurePrefsReliabilityExperiment(testContext)() + Mockito.verify(processor, times(factPairs.size)).process(this.capture()) + assertEquals(factPairs.size, this.allValues.size) + factPairs.forEachIndexed { index, pair -> + assertExperimentFact(this.allValues[index], pair.first, pair.second) + } + } + reset(processor) + } + + private fun assertExperimentFact( + fact: Fact, + item: String, + value: Values, + metadata: Map<String, Any>? = null, + ) { + assertEquals(Component.LIB_DATAPROTECT, fact.component) + assertEquals(Action.IMPLEMENTATION_DETAIL, fact.action) + assertEquals(item, fact.item) + assertEquals("${value.v}", fact.value) + assertEquals(metadata, fact.metadata) + } +} diff --git a/mobile/android/android-components/components/lib/dataprotect/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/dataprotect/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/dataprotect/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/README.md b/mobile/android/android-components/components/lib/fetch-httpurlconnection/README.md new file mode 100644 index 0000000000..21ed90d434 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/README.md @@ -0,0 +1,25 @@ +# [Android Components](../../../README.md) > Libraries > Fetch-HttpURLConnection + +A [concept-fetch](../../concept/fetch/README.md) implementation using [HttpURLConnection](https://developer.android.com/reference/java/net/HttpURLConnection.html). + +This implementation of `concept-fetch` uses [HttpURLConnection](https://developer.android.com/reference/java/net/HttpURLConnection.html) from the standard library of the Android System. Therefore this component has no third-party dependencies and is smaller than other implementations. It's intended use is for apps that have strict APK size constraints. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-fetch-httpurlconnection:{latest-version}" +``` + +### Performing requests + +See the [concept-fetch documentation](../../concept/fetch/README.md) for generic examples of using the API of components implementing `concept-fetch`. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/build.gradle b/mobile/android/android-components/components/lib/fetch-httpurlconnection/build.gradle new file mode 100644 index 0000000000..7065a851cb --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/build.gradle @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.fetch.httpurlconnection' +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + + implementation project(':concept-fetch') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_mockito + + testImplementation project(':tooling-fetch-tests') +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/proguard-rules.pro b/mobile/android/android-components/components/lib/fetch-httpurlconnection/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt new file mode 100644 index 0000000000..7ded64ea15 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/main/java/mozilla/components/lib/fetch/httpurlconnection/HttpURLConnectionClient.kt @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.fetch.httpurlconnection + +import mozilla.components.concept.fetch.BuildConfig +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.isDataUri +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient.Companion.getOrCreateCookieManager +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.net.CookieHandler +import java.net.CookieManager +import java.net.HttpURLConnection +import java.net.URL +import java.util.zip.GZIPInputStream + +/** + * [HttpURLConnection] implementation of [Client]. + */ +class HttpURLConnectionClient : Client() { + private val defaultHeaders: Headers = MutableHeaders( + "User-Agent" to "MozacFetch/${BuildConfig.LIBRARY_VERSION}", + "Accept-Encoding" to "gzip", + ) + + @Throws(IOException::class) + override fun fetch(request: Request): Response { + if (request.private) { + throw IllegalArgumentException("Client doesn't support private request") + } + if (request.isDataUri()) { + return fetchDataUri(request) + } + + val connection = (URL(request.url).openConnection() as HttpURLConnection) + + connection.setupWith(request) + connection.addHeadersFrom(request, defaultHeaders) + connection.addBodyFrom(request) + + return connection.toResponse() + } + + companion object { + fun getOrCreateCookieManager(): CookieManager { + if (CookieHandler.getDefault() == null) { + CookieHandler.setDefault(CookieManager()) + } + return CookieHandler.getDefault() as CookieManager + } + } +} + +private fun HttpURLConnection.addBodyFrom(request: Request) { + if (request.body == null) { + return + } + + request.body?.let { body -> + doOutput = true + + body.useStream { inStream -> + outputStream.use { outStream -> + inStream + .buffered() + .copyTo(outStream) + outStream.flush() + } + } + } +} + +internal fun HttpURLConnection.setupWith(request: Request) { + requestMethod = request.method.name + instanceFollowRedirects = request.redirect == Request.Redirect.FOLLOW + + request.connectTimeout?.let { (timeout, unit) -> + connectTimeout = unit.toMillis(timeout).toInt() + } + + request.readTimeout?.let { (timeout, unit) -> + readTimeout = unit.toMillis(timeout).toInt() + } + + useCaches = request.useCaches + + // HttpURLConnection can't be configured to omit cookies. As + // a workaround, we delete all cookies we have stored for + // the request URI. + val cookieManager = getOrCreateCookieManager() + if (request.cookiePolicy == Request.CookiePolicy.OMIT) { + val uri = URL(request.url).toURI() + for (cookie in cookieManager.cookieStore.get(uri)) { + cookieManager.cookieStore.remove(uri, cookie) + } + } +} + +private fun HttpURLConnection.addHeadersFrom(request: Request, defaultHeaders: Headers) { + defaultHeaders.filter { header -> + request.headers?.contains(header.name) != true + }.forEach { header -> + setRequestProperty(header.name, header.value) + } + + request.headers?.forEach { header -> + addRequestProperty(header.name, header.value) + } +} + +private fun HttpURLConnection.toResponse(): Response { + val headers = translateHeaders(this) + return Response( + url.toString(), + responseCode, + headers, + createBody(this, headers["Content-Type"]), + ) +} + +private fun translateHeaders(connection: HttpURLConnection): Headers { + val headers = MutableHeaders() + + var index = 0 + + while (connection.getHeaderField(index) != null) { + val name = connection.getHeaderFieldKey(index) + if (name == null) { + index++ + continue + } + + val value = connection.getHeaderField(index) + + headers.append(name, value) + + index++ + } + + return headers +} + +private fun createBody(connection: HttpURLConnection, contentType: String?): Response.Body { + val gzipped = connection.contentEncoding == "gzip" + + withFileNotFoundExceptionIgnored { + return HttpUrlConnectionBody( + connection, + connection.inputStream, + gzipped, + contentType, + ) + } + + withFileNotFoundExceptionIgnored { + return HttpUrlConnectionBody( + connection, + connection.errorStream, + gzipped, + contentType, + ) + } + + return EmptyBody() +} + +private class EmptyBody : Response.Body("".byteInputStream()) + +private class HttpUrlConnectionBody( + private val connection: HttpURLConnection, + stream: InputStream, + gzipped: Boolean, + contentType: String?, +) : Response.Body(if (gzipped) GZIPInputStream(stream) else stream, contentType) { + override fun close() { + super.close() + + connection.disconnect() + } +} + +private inline fun withFileNotFoundExceptionIgnored(block: () -> Unit) { + try { + block() + } catch (e: FileNotFoundException) { + // Ignore + } +} diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt new file mode 100644 index 0000000000..77b086c8d3 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/java/mozilla/components/lib/fetch/httpurlconnection/HttpUrlConnectionFetchTestCases.kt @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.fetch.httpurlconnection + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.net.HttpURLConnection +import java.net.URL + +@RunWith(AndroidJUnit4::class) +class HttpUrlConnectionFetchTestCases : mozilla.components.tooling.fetch.tests.FetchTestCases() { + override fun createNewClient(): Client = HttpURLConnectionClient() + + // Inherits test methods from generic test suite base class + + @Test + fun `Client instance`() { + // We need at least one test case defined here so that this is recognized as test class. + assertTrue(createNewClient() is HttpURLConnectionClient) + } + + @Test + override fun get200WithCacheControl() { + // We can't run the base fetch test case because HttpResponseCache + // doesn't work in a unit test. So we test that we set the + // flag correctly instead. + val connection = (URL("https://mozilla.org").openConnection() as HttpURLConnection) + assertTrue(connection.useCaches) + + connection.setupWith((Request("https://mozilla.org", useCaches = false))) + assertFalse(connection.useCaches) + } +} diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-httpurlconnection/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/README.md b/mobile/android/android-components/components/lib/fetch-okhttp/README.md new file mode 100644 index 0000000000..d1cd0a3d2f --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/README.md @@ -0,0 +1,25 @@ +# [Android Components](../../../README.md) > Libraries > Fetch-OkHttp + +A [concept-fetch](../../concept/fetch/README.md) implementation using [OkHttp](https://github.com/square/okhttp). + +This implementation of `concept-fetch` uses [OkHttp](https://github.com/square/okhttp) - a third-party library from Square. It is intended for apps that already use OkHttp and want components to use the same client. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-fetch-okhttp:{latest-version}" +``` + +### Performing requests + +See the [concept-fetch documentation](../../concept/fetch/README.md) for generic examples of using the API of components implementing `concept-fetch`. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/build.gradle b/mobile/android/android-components/components/lib/fetch-okhttp/build.gradle new file mode 100644 index 0000000000..c7f1dd9495 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/build.gradle @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.fetch.okhttp' + +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + + implementation ComponentsDependencies.thirdparty_okhttp + implementation ComponentsDependencies.thirdparty_okhttp_urlconnection + + implementation project(':concept-fetch') + + testImplementation project(':support-test') + + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation project(':tooling-fetch-tests') +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/proguard-rules.pro b/mobile/android/android-components/components/lib/fetch-okhttp/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/fetch-okhttp/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt b/mobile/android/android-components/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt new file mode 100644 index 0000000000..0b885eee44 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/src/main/java/mozilla/components/lib/fetch/okhttp/OkHttpClient.kt @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.fetch.okhttp + +import android.content.Context +import mozilla.components.concept.fetch.BuildConfig +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.isDataUri +import mozilla.components.lib.fetch.okhttp.OkHttpClient.Companion.CACHE_MAX_SIZE +import mozilla.components.lib.fetch.okhttp.OkHttpClient.Companion.getOrCreateCookieManager +import okhttp3.Cache +import okhttp3.CacheControl +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.CookieHandler +import java.net.CookieManager + +typealias RequestBuilder = okhttp3.Request.Builder + +/** + * [Client] implementation using OkHttp. + */ +class OkHttpClient( + private val client: OkHttpClient = OkHttpClient(), + private val context: Context? = null, +) : Client() { + private val defaultHeaders: Headers = MutableHeaders( + "User-Agent" to "MozacFetch/${BuildConfig.LIBRARY_VERSION}", + "Accept-Encoding" to "gzip", + ) + + override fun fetch(request: Request): Response { + if (request.private) { + throw IllegalArgumentException("Client doesn't support private request") + } + + if (request.isDataUri()) { + return fetchDataUri(request) + } + + val requestClient = client.rebuildFor(request, context) + + val requestBuilder = createRequestBuilderWithBody(request) + requestBuilder.addHeadersFrom(request, defaultHeaders = defaultHeaders) + + if (!request.useCaches) { + requestBuilder.cacheControl(CacheControl.FORCE_NETWORK) + } + + val actualResponse = requestClient.newCall( + requestBuilder.build(), + ).execute() + + return actualResponse.toResponse() + } + + companion object { + internal const val CACHE_MAX_SIZE: Long = 10L * 1024L * 1024L + + fun getOrCreateCookieManager(): CookieManager { + if (CookieHandler.getDefault() == null) { + CookieHandler.setDefault(CookieManager()) + } + return CookieHandler.getDefault() as CookieManager + } + } +} + +private fun OkHttpClient.rebuildFor(request: Request, context: Context?): OkHttpClient { + @Suppress("ComplexCondition") + if (request.connectTimeout != null || + request.readTimeout != null || + request.redirect != Request.Redirect.FOLLOW || + request.cookiePolicy != Request.CookiePolicy.OMIT + ) { + val clientBuilder = newBuilder() + + request.connectTimeout?.let { (timeout, unit) -> clientBuilder.connectTimeout(timeout, unit) } + request.readTimeout?.let { (timeout, unit) -> clientBuilder.readTimeout(timeout, unit) } + + if (request.redirect == Request.Redirect.MANUAL) { + clientBuilder.followRedirects(false) + } + + if (request.cookiePolicy == Request.CookiePolicy.INCLUDE) { + clientBuilder.cookieJar(JavaNetCookieJar(getOrCreateCookieManager())) + } + + context?.let { + clientBuilder.cache(Cache(context.cacheDir, CACHE_MAX_SIZE)) + } + + return clientBuilder.build() + } + + return this +} + +private fun okhttp3.Response.toResponse(): Response { + val body = body + val headers = translateHeaders(headers) + + return Response( + url = request.url.toString(), + headers = headers, + status = code, + body = if (body != null) Response.Body(body.byteStream(), headers["Content-Type"]) else Response.Body.empty(), + ) +} + +private fun createRequestBuilderWithBody(request: Request): RequestBuilder { + val requestBody = request.body?.useStream { it.readBytes() }?.let { + it.toRequestBody(null, 0, it.size) + } + + return RequestBuilder() + .url(request.url) + .method(request.method.name, requestBody) +} + +private fun RequestBuilder.addHeadersFrom(request: Request, defaultHeaders: Headers) { + defaultHeaders + .filter { header -> + request.headers?.contains(header.name) != true + }.filter { header -> + header.name != "Accept-Encoding" && header.value != "gzip" + }.forEach { header -> + addHeader(header.name, header.value) + } + + request.headers?.forEach { header -> addHeader(header.name, header.value) } +} + +private fun translateHeaders(actualHeaders: okhttp3.Headers): Headers { + val headers = MutableHeaders() + + for (i in 0 until actualHeaders.size) { + headers.append(actualHeaders.name(i), actualHeaders.value(i)) + } + + return headers +} diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/src/test/java/mozilla/components/lib/fetch/okhttp/OkHttpFetchTestCases.kt b/mobile/android/android-components/components/lib/fetch-okhttp/src/test/java/mozilla/components/lib/fetch/okhttp/OkHttpFetchTestCases.kt new file mode 100644 index 0000000000..50610409c3 --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/src/test/java/mozilla/components/lib/fetch/okhttp/OkHttpFetchTestCases.kt @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.fetch.okhttp + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.tooling.fetch.tests.FetchTestCases +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OkHttpFetchTestCases : FetchTestCases() { + + override fun createNewClient(): Client = OkHttpClient(okhttp3.OkHttpClient(), testContext) + + // Inherits test methods from generic test suite base class + + @Test + fun `Client instance`() { + // We need at least one test case defined here so that this is recognized as test class. + assertTrue(createNewClient() is OkHttpClient) + } +} diff --git a/mobile/android/android-components/components/lib/fetch-okhttp/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/fetch-okhttp/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/fetch-okhttp/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/jexl/README.md b/mobile/android/android-components/components/lib/jexl/README.md new file mode 100644 index 0000000000..646034363a --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/README.md @@ -0,0 +1,236 @@ +# [Android Components](../../../README.md) > Libraries > JEXL + +Javascript Expression Language: Powerful context-based expression parser and evaluator. + +This implementation is based on [Mozjexl](https://github.com/mozilla/mozjexl), a fork of Jexl (designed and created at TechnologyAdvice) for use at Mozilla, specifically as a part of SHIELD and Normandy. + +Features not supported yet: + +* JavaScript object properties (e.g. [String.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length)) +* Adding custom operators (binary/unary) + +Other implementations: + +* [JavaScript](https://github.com/mozilla/mozjexl) +* [Python](https://github.com/mozilla/pyjexl) + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:jexl:{latest-version} +``` + +### Evaluating expressions + +```Kotlin +val jexl = Jexl() + +val result = jexl.evaluate("75 > 42") + +// evaluate() returns an object of type JexlValue. Calling toKotlin() converts this +// into a matching Kotlin type (in this case a Boolean). +println(result.value) // Prints "true" +``` + +Often expressions should return a `Boolean`value. In this case `evaluateBooleanExpression` is a helper that always returns a Kotlin `Boolean` and never throws an exception (Returns false). + +```Kotlin +val jexl = Jexl() + +// "result" has type Boolean and value "true" +val result = jexl.evaluateBooleanExpression("42 + 23 > 50", defaultValue = false) +``` + + +### Unary Operators + +| Operation | Symbol | +|-----------|:------:| +| Negate | ! | + +### Binary Operators + +| Operation | Symbol | +|------------------|:----------------:| +| Add, Concat | + | +| Subtract | - | +| Multiply | * | +| Divide | / | +| Divide and floor | // | +| Modulus | % | +| Power of | ^ | +| Logical AND | && | +| Logical OR | || | + +### Comparison + +| Comparison | Symbol | +|----------------------------|:------:| +| Equal | == | +| Not equal | != | +| Greater than | > | +| Greater than or equal | >= | +| Less than | < | +| Less than or equal | <= | +| Element in array or string | in | + +### Ternary operator + +Conditional expressions check to see if the first segment evaluates to a truthy +value. If so, the consequent segment is evaluated. Otherwise, the alternate +is. If the consequent section is missing, the test result itself will be used +instead. + +| Expression | Result | +|-------------------------------------|--------| +| `"" ? "Full" : "Empty"` | Empty | +| `"foo" in "foobar" ? "Yes" : "No"` | Yes | +| `{agent: "Archer"}.agent ?: "Kane"` | Archer | + +### Native Types + +| Type | Examples | +|-----------|:------------------------------:| +| Booleans | `true`, `false` | +| Strings | "Hello \"user\"", 'Hey there!' | +| Integers | 6, -7, 5, -3 | +| Doubles | -7.2, -3.14159 | +| Objects | {hello: "world!"} | +| Arrays | ['hello', 'world!'] | +| Undefined | `undefined` | + +The JavaScript implementation of Jexl uses a `Numeric` type. This implementation dynamically casts between `Integer` and `Double` as needed. + +### Groups + +Parentheses work just how you'd expect them to: + +| Expression | Result | +|---------------------------------------|:-------| +| `(83 + 1) / 2` | 42 | +| `1 < 3 && (4 > 2 || 2 > 4)` | true | + +### Identifiers + +Access variables in the context object by just typing their name. Objects can +be traversed with dot notation, or by using brackets to traverse to a dynamic +property name. + +Example context: + +```javascript +{ + name: { + first: "Malory", + last: "Archer" + }, + exes: [ + "Nikolai Jakov", + "Len Trexler", + "Burt Reynolds" + ], + lastEx: 2 +} +``` + +| Expression | Result | +|---------------------|---------------| +| `name.first` | Malory | +| `name['la' + 'st']` | Archer | +| `exes[2]` | Burt Reynolds | +| `exes[lastEx - 1]` | Len Trexler | + +### Collections + +Collections, or arrays of objects, can be filtered by including a filter +expression in brackets. Properties of each collection can be referenced by +prefixing them with a leading dot. The result will be an array of the objects +for which the filter expression resulted in a truthy value. + +Example context: + +```javascript +{ + employees: [ + {first: 'Sterling', last: 'Archer', age: 36}, + {first: 'Malory', last: 'Archer', age: 75}, + {first: 'Lana', last: 'Kane', age: 33}, + {first: 'Cyril', last: 'Figgis', age: 45}, + {first: 'Cheryl', last: 'Tunt', age: 28} + ], + retireAge: 62 +} +``` + +| Expression | Result | +|-------------------------------------------------|---------------------------------------------------------------------------------------| +| `employees[.first == 'Sterling']` | [{first: 'Sterling', last: 'Archer', age: 36}] | +| `employees[.last == 'Tu' + 'nt'].first` | Cheryl | +| `employees[.age >= 30 && .age < 40]` | [{first: 'Sterling', last: 'Archer', age: 36},{first: 'Lana', last: 'Kane', age: 33}] | +| `employees[.age >= 30 && .age < 40][.age < 35]` | [{first: 'Lana', last: 'Kane', age: 33}] | +| `employees[.age >= retireAge].first` | Malory | + +### Transforms + +The power of Jexl is in transforming data. Transform functions take one or more arguments: The value to be transformed, followed by anything else passed to it in the expression. + +```Kotlin +val jexl = Jexl() + +jexl.addTransform("split") { value, arguments -> + value.toString().split(arguments.first().toString()).toJexlArray() +} + +jexl.addTransform("lower") { value, _ -> + value.toString().toLowerCase().toJexl() +} + +jexl.addTransform("last") { value, _ -> + (value as JexlArray).values.last() +} +``` + +| Expression | Result | +|-------------------------------------------------|-----------------------| +| `"Pam Poovey"|lower|split(' ')|first` | poovey | +| `"password==guest"|split('=' + '=')` | ['password', 'guest'] | + +### Context + +Variable contexts are straightforward Objects that can be accessed +in the expression. + +```Kotlin +val context = Context( + "employees" to JexlArray( + JexlObject( + "first" to "Sterling".toJexl(), + "last" to "Archer".toJexl(), + "age" to 36.toJexl()), + JexlObject( + "first" to "Malory".toJexl(), + "last" to "Archer".toJexl(), + "age" to 75.toJexl()), + JexlObject( + "first" to "Malory".toJexl(), + "last" to "Archer".toJexl(), + "age" to 33.toJexl()) + ) +) +``` + +| Expression | Result | +|-------------------------------------------------|------------------------------------------------------------------------------| +| `employees[.age >= 30 && .age < 40]` | [{first=Sterling, last=Archer, age=36}, {first=Malory, last=Archer, age=33}] | +| `employees[.age >= 30 && .age < 90][.age < 37]` | [{first=Malory, last=Archer, age=33}] | + + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/jexl/build.gradle b/mobile/android/android-components/components/lib/jexl/build.gradle new file mode 100644 index 0000000000..0c4e21a5af --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/build.gradle @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.jexl' +} + +dependencies { + testImplementation ComponentsDependencies.testing_junit + testImplementation ComponentsDependencies.testing_mockito +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/jexl/proguard-rules.pro b/mobile/android/android-components/components/lib/jexl/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/jexl/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/jexl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..41078a7325 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/Jexl.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/Jexl.kt new file mode 100644 index 0000000000..f76544fb23 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/Jexl.kt @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl + +import mozilla.components.lib.jexl.evaluator.Evaluator +import mozilla.components.lib.jexl.evaluator.EvaluatorException +import mozilla.components.lib.jexl.evaluator.JexlContext +import mozilla.components.lib.jexl.evaluator.Transform +import mozilla.components.lib.jexl.grammar.Grammar +import mozilla.components.lib.jexl.lexer.Lexer +import mozilla.components.lib.jexl.lexer.LexerException +import mozilla.components.lib.jexl.parser.Parser +import mozilla.components.lib.jexl.parser.ParserException +import mozilla.components.lib.jexl.value.JexlUndefined +import mozilla.components.lib.jexl.value.JexlValue + +class Jexl( + private val grammar: Grammar = Grammar(), +) { + private val lexer: Lexer = Lexer(grammar) + private val transforms: MutableMap<String, Transform> = mutableMapOf() + + /** + * Adds or replaces a transform function in this Jexl instance. + * + * @param name The name of the transform function, as it will be used within Jexl expressions. + * @param transform The function to be executed when this transform is invoked. It will be + * provided with two arguments: + * - value: The value to be transformed + * - arguments: The list of arguments for this transform. + */ + fun addTransform(name: String, transform: Transform) { + transforms[name] = transform + } + + /** + * Evaluates a Jexl string within an optional context. + * + * @param expression The Jexl expression to be evaluated. + * @param context A mapping of variables to values, which will be made accessible to the Jexl + * expression when evaluating it. + * @return The result of the evaluation. + * @throws JexlException if lexing, parsing or evaluating the expression failed. + */ + @Throws(JexlException::class) + @Suppress("ThrowsCount") + fun evaluate(expression: String, context: JexlContext = JexlContext()): JexlValue { + val parser = Parser(grammar) + val evaluator = Evaluator(context, grammar, transforms) + + try { + val tokens = lexer.tokenize(expression) + val astTree = parser.parse(tokens) + ?: return JexlUndefined() + + return evaluator.evaluate(astTree) + } catch (e: LexerException) { + throw JexlException(e) + } catch (e: ParserException) { + throw JexlException(e) + } catch (e: EvaluatorException) { + throw JexlException(e) + } + } + + /** + * Evaluates a Jexl string with an optional context to a Boolean result. Optionally a default + * value can be provided that will be returned in the expression does not return a boolean + * result. + */ + fun evaluateBooleanExpression( + expression: String, + context: JexlContext = JexlContext(), + defaultValue: Boolean? = null, + ): Boolean { + val result = try { + evaluate(expression, context) + } catch (e: EvaluatorException) { + throw JexlException(e) + } + + return try { + result.toBoolean() + } catch (e: EvaluatorException) { + if (defaultValue != null) { + return defaultValue + } else { + throw JexlException(e) + } + } + } +} + +/** + * Generic exception thrown when evaluating an expression failed. + */ +class JexlException( + cause: Exception? = null, + message: String? = null, +) : Exception(message, cause) diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/ast/nodes.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/ast/nodes.kt new file mode 100644 index 0000000000..5a7858db1b --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/ast/nodes.kt @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.ast + +/** + * A node of the abstract syntax tree. + */ +sealed class AstNode { + + var parent: AstNode? = null + + open fun toString(level: Int, isTopLevel: Boolean = true): String = toString() +} + +internal interface OperatorNode { + val operator: String? +} + +internal interface BranchNode { + var right: AstNode? +} + +// node types + +internal data class Literal( + val value: Any?, +) : AstNode() { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription("< $value >", "LITERAL", level, isTopLevel) + + override fun toString() = toString(level = 0) +} + +internal data class BinaryExpression( + override val operator: String?, + var left: AstNode?, + override var right: AstNode? = null, +) : AstNode(), OperatorNode, BranchNode { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription("[ $operator ]", "BINARY_EXPRESSION", level, isTopLevel) { + appendChildNode(left, "left", level + 1) + appendChildNode(right, "right", level + 1) + } + + override fun toString() = toString(level = 0) +} + +internal data class UnaryExpression( + override val operator: String?, + override var right: AstNode? = null, +) : AstNode(), OperatorNode, BranchNode { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription("[ $operator ]", "UNARY_EXPRESSION", level, isTopLevel) { + appendChildNode(right, "right", level + 1) + } + + override fun toString() = toString(level = 0) +} + +internal data class Identifier( + var value: Any?, + var from: AstNode? = null, + var relative: Boolean = false, +) : AstNode() { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription( + "< $value >", + "IDENTIFIER", + level, + isTopLevel, + withinHeader = { append(" [ relative = $relative ]") }, + ) { + appendChildNode(from, "from", level + 1) + } + + override fun toString() = toString(level = 0) +} + +internal data class ObjectLiteral( + val properties: Map<String, AstNode>, +) : AstNode() { + + constructor(vararg properties: Pair<String, AstNode>) : this(properties.toMap()) + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription("<Object>", "OBJECT_LITERAL", level, isTopLevel) { + appendNodeMapValues(this@ObjectLiteral, level + 1) + } + + override fun toString() = toString(level = 0) +} + +internal data class ConditionalExpression( + var test: AstNode?, + var consequent: AstNode? = null, + var alternate: AstNode? = null, +) : AstNode() { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription("< ? >", "CONDITIONAL_EXPRESSION", level, isTopLevel) { + appendChildNode(test, "test", level + 1) + appendChildNode(consequent, "consequent", level + 1) + appendChildNode(alternate, "alternate", level + 1) + } + + override fun toString() = toString(level = 0) +} + +internal data class ArrayLiteral( + val values: MutableList<Any?>, +) : AstNode() { + + constructor(vararg values: Any?) : this(values.toMutableList()) + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription( + "[Array]", + "ARRAY_LITERAL", + level, + isTopLevel, + withinHeader = { append(" [ size = ${values.size} ]") }, + ) { + appendNodeListValues(this@ArrayLiteral, level + 1) + } + + override fun toString() = toString(level = 0) +} + +internal data class Transformation( + var name: String?, + val arguments: MutableList<AstNode> = mutableListOf(), + var subject: AstNode? = null, +) : AstNode() { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription("( $name )", "TRANSFORMATION", level, isTopLevel) { + appendChildNode(subject, "subject", level + 1) + + for (argument in arguments) { + appendChildNode(argument, "arg", level + 1) + } + } + + override fun toString() = toString(level = 0) +} + +internal data class FilterExpression( + var expression: AstNode?, + var subject: AstNode?, + var relative: Boolean, +) : AstNode() { + + override fun toString(level: Int, isTopLevel: Boolean) = + buildNodeDescription( + "[ . ]", + "FILTER_EXPRESSION", + level, + isTopLevel, + withinHeader = { append(" [ relative = $relative ]") }, + ) { + appendChildNode(expression, "expression", level + 1) + appendChildNode(subject, "subject", level + 1) + } + + override fun toString() = toString(level = 0) +} + +// string representation helpers + +private fun buildNodeDescription( + value: String, + name: String, + level: Int, + isTopLevel: Boolean = true, + withinHeader: StringBuilder.() -> Unit = {}, + block: StringBuilder.() -> Unit = {}, +) = buildString { + if (isTopLevel) { + appendLevelPad(level) + } + + append(value) + append(" ( $name )") + + withinHeader() + appendLine() + + block() +} + +private fun StringBuilder.appendNodeMapValues(node: ObjectLiteral, level: Int) { + node.properties.forEach { (key, value) -> + val objectValue = value.toString(level, isTopLevel = false) + + appendLevelPad(level) + append("$key : $objectValue") + } +} + +private fun StringBuilder.appendNodeListValues(node: ArrayLiteral, level: Int) { + val array = node.values + + array.withIndex().forEach { (i, child) -> + appendLevelPad(level) + + val value = if (child is AstNode) { + child.toString(level, isTopLevel = false) + } else { + child.toString() + } + + append("$i : $value") + } +} + +private fun StringBuilder.appendChildNode(node: AstNode?, name: String, level: Int) { + node ?: return + + appendLevelPad(level) + append("$name = ") + append(node.toString(level, isTopLevel = false)) +} + +private fun StringBuilder.appendLevelPad(level: Int) = append("".padStart(level * 2, ' ')) diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/Evaluator.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/Evaluator.kt new file mode 100644 index 0000000000..654abf465f --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/Evaluator.kt @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.evaluator + +import mozilla.components.lib.jexl.ast.AstNode +import mozilla.components.lib.jexl.grammar.Grammar +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlBoolean +import mozilla.components.lib.jexl.value.JexlDouble +import mozilla.components.lib.jexl.value.JexlInteger +import mozilla.components.lib.jexl.value.JexlObject +import mozilla.components.lib.jexl.value.JexlString +import mozilla.components.lib.jexl.value.JexlUndefined +import mozilla.components.lib.jexl.value.JexlValue + +typealias Transform = (JexlValue, List<JexlValue>) -> JexlValue + +internal class EvaluatorException(message: String) : Exception(message) + +/** + * The evaluator takes a JEXL abstract syntax tree as generated by the <code>Parser</code> and calculates its value + * within a given context. + */ +internal class Evaluator( + internal val context: JexlContext = JexlContext(), + internal val grammar: Grammar = Grammar(), + internal val transforms: Map<String, Transform> = emptyMap(), + internal val relativeContext: JexlObject = JexlObject(), +) { + + @Throws(EvaluatorException::class) + fun evaluate(node: AstNode): JexlValue = + EvaluatorHandlers.evaluateWith(this, node) + + internal fun evaluateArray(nodes: List<AstNode>): List<JexlValue> { + return nodes.map { evaluate(it) } + } + + internal fun evaluateObject(properties: Map<String, AstNode>): Map<String, JexlValue> { + return properties.mapValues { entry -> + evaluate(entry.value) + } + } + + fun filterRelative(subject: JexlValue, expression: AstNode): JexlValue { + val filterSubject = subject as? JexlArray ?: JexlArray( + subject, + ) + + val values = filterSubject.value.filter { element -> + val subContext = element as? JexlObject ?: JexlObject() + val evaluator = Evaluator(context, grammar, transforms, subContext) + val value = evaluator.evaluate(expression) + + value.value as Boolean + } + + return JexlArray(values) + } + + fun filterStatic(subject: JexlValue, expression: AstNode): JexlValue { + val result = evaluate(expression) + + return when { + result is JexlBoolean -> if (result.value) { subject } else { + JexlUndefined() + } + + subject is JexlUndefined -> subject + + subject is JexlObject && result is JexlString -> subject.value[result.value] + ?: JexlUndefined() + + subject is JexlArray && result is JexlInteger -> subject.value.getOrNull(result.value) + ?: JexlUndefined() + + // We just convert a double to int here .. hoping for the best! + subject is JexlArray && result is JexlDouble -> subject.value.getOrNull(result.value.toInt()) + ?: JexlUndefined() + + else -> throw EvaluatorException("Cannot filter $subject by $result") + } + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/EvaluatorHandlers.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/EvaluatorHandlers.kt new file mode 100644 index 0000000000..a53e2a7aa5 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/EvaluatorHandlers.kt @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.evaluator + +import mozilla.components.lib.jexl.JexlException +import mozilla.components.lib.jexl.ast.ArrayLiteral +import mozilla.components.lib.jexl.ast.AstNode +import mozilla.components.lib.jexl.ast.BinaryExpression +import mozilla.components.lib.jexl.ast.ConditionalExpression +import mozilla.components.lib.jexl.ast.FilterExpression +import mozilla.components.lib.jexl.ast.Identifier +import mozilla.components.lib.jexl.ast.Literal +import mozilla.components.lib.jexl.ast.ObjectLiteral +import mozilla.components.lib.jexl.ast.Transformation +import mozilla.components.lib.jexl.ast.UnaryExpression +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlBoolean +import mozilla.components.lib.jexl.value.JexlDouble +import mozilla.components.lib.jexl.value.JexlInteger +import mozilla.components.lib.jexl.value.JexlObject +import mozilla.components.lib.jexl.value.JexlString +import mozilla.components.lib.jexl.value.JexlUndefined +import mozilla.components.lib.jexl.value.JexlValue + +/** + * A mapping of AST node type to a function that can evaluate this type of node. + * + * This mapping could be moved inside [Evaluator]. + */ +internal object EvaluatorHandlers { + + internal fun evaluateWith(evaluator: Evaluator, node: AstNode): JexlValue = when (node) { + is Literal -> evaluateLiteral(node) + is BinaryExpression -> evaluateBinaryExpression(evaluator, node) + is Identifier -> evaluateIdentifier(evaluator, node) + is ObjectLiteral -> evaluateObjectLiteral(evaluator, node) + is ArrayLiteral -> evaluateArrayLiteral(evaluator, node) + is ConditionalExpression -> evaluateConditionalExpression(evaluator, node) + is Transformation -> evaluateTransformation(evaluator, node) + is FilterExpression -> evaluateFilterExpression(evaluator, node) + is UnaryExpression -> throw JexlException( + message = "Unary expression evaluation can't be validated", + ) + } + + private fun evaluateLiteral(node: Literal): JexlValue = when (val value = node.value) { + is String -> JexlString(value) + is Double -> JexlDouble(value) + is Int -> JexlInteger(value) + is Boolean -> JexlBoolean(value) + else -> throw EvaluatorException("Unknown value type: ${value!!::class}") + } + + private fun evaluateBinaryExpression(evaluator: Evaluator, node: BinaryExpression): JexlValue { + val left = evaluator.evaluate(node.left!!) + val right = evaluator.evaluate(node.right!!) + val operator = evaluator.grammar.elements[node.operator!!] + + return operator!!.evaluate?.invoke(left, right) + ?: throw EvaluatorException("Can't evaluate _operator: ${node.operator}") + } + + private fun evaluateIdentifier(evaluator: Evaluator, node: Identifier): JexlValue = + if (node.from != null) { + evaluateIdentifierWithScope(evaluator, node) + } else { + evaluateIdentifierWithoutScope(evaluator, node) + } + + private fun evaluateIdentifierWithScope(evaluator: Evaluator, node: Identifier): JexlValue = + when (val subContext = evaluator.evaluate(node.from!!)) { + is JexlArray -> { + val obj = subContext.value[0] + + when (obj) { + is JexlUndefined -> obj + + is JexlObject -> obj.value[node.value.toString()] + ?: throw EvaluatorException("${node.value} is undefined") + + else -> throw EvaluatorException("$obj is not an object") + } + } + + is JexlObject -> subContext.value[node.value.toString()] + ?: JexlUndefined() + + else -> JexlUndefined() + } + + private fun evaluateIdentifierWithoutScope(evaluator: Evaluator, node: Identifier): JexlValue = + if (node.relative) { + evaluator.relativeContext.value[(node.value.toString())] + ?: JexlUndefined() + } else { + evaluator.context.get(node.value.toString()) + } + + private fun evaluateObjectLiteral(evaluator: Evaluator, node: ObjectLiteral): JexlValue { + val properties = evaluator.evaluateObject(node.properties) + return JexlObject(properties) + } + + private fun evaluateArrayLiteral(evaluator: Evaluator, node: ArrayLiteral): JexlValue { + @Suppress("UNCHECKED_CAST") + val values = evaluator.evaluateArray(node.values as List<AstNode>) + return JexlArray(values) + } + + private fun evaluateConditionalExpression(evaluator: Evaluator, node: ConditionalExpression): JexlValue { + val result = evaluator.evaluate(node.test!!) + + return if (result.toBoolean()) { + if (node.consequent != null) { + evaluator.evaluate(node.consequent!!) + } else { + result + } + } else { + evaluator.evaluate(node.alternate!!) + } + } + + private fun evaluateTransformation(evaluator: Evaluator, node: Transformation): JexlValue { + val transform = evaluator.transforms[node.name] + ?: throw EvaluatorException("Unknown transform ${node.name}") + + if (node.subject == null) { + throw EvaluatorException("Missing subject for transform") + } + + val subject = evaluator.evaluate(node.subject!!) + val arguments = evaluator.evaluateArray(node.arguments) + + return transform.invoke(subject, arguments) + } + + private fun evaluateFilterExpression(evaluator: Evaluator, node: FilterExpression): JexlValue { + if (node.subject == null) { + throw EvaluatorException("Missing subject for filter expression") + } + + val subject = evaluator.evaluate(node.subject!!) + + if (node.expression == null) { + throw EvaluatorException("Missing expression for filter expression") + } + + return if (node.relative) { + val result = evaluator.filterRelative(subject, node.expression!!) + result + } else { + evaluator.filterStatic(subject, node.expression!!) + } + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/JexlContext.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/JexlContext.kt new file mode 100644 index 0000000000..98397aafe9 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/evaluator/JexlContext.kt @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.evaluator + +import mozilla.components.lib.jexl.value.JexlValue + +/** + * Variables defined in the [JexlContext] are available to expressions. + * + * Example context: + * <code> + * val context = JexlContext( + * "employees" to JexlArray( + * JexlObject( + * "first" to "Sterling".toJexl(), + * "last" to "Archer".toJexl(), + * "age" to 36.toJexl()), + * JexlObject( + * "first" to "Malory".toJexl(), + * "last" to "Archer".toJexl(), + * "age" to 75.toJexl()), + * JexlObject( + * "first" to "Malory".toJexl(), + * "last" to "Archer".toJexl(), + * "age" to 33.toJexl()) + * ) + * ) + * </code> + * + * This context can be accessed in an JEXL expression like this: + * + * <code> + * employees[.age >= 30 && .age < 90][.age < 35] + * </code> + */ +class JexlContext( + vararg pairs: Pair<String, JexlValue>, +) { + private val properties: MutableMap<String, JexlValue> = pairs.toMap().toMutableMap() + + fun set(key: String, value: JexlValue) { + properties[key] = value + } + + fun get(key: String): JexlValue { + return properties[key] + ?: throw EvaluatorException("$key is undefined") + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/ext/JexlExtensions.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/ext/JexlExtensions.kt new file mode 100644 index 0000000000..b2faedff49 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/ext/JexlExtensions.kt @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.ext + +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlBoolean +import mozilla.components.lib.jexl.value.JexlDouble +import mozilla.components.lib.jexl.value.JexlInteger +import mozilla.components.lib.jexl.value.JexlString + +// Kotlin extensions that make it easier to work with Jexl types and values + +inline fun <reified T> List<T>.toJexlArray(): JexlArray { + val values = when (T::class) { + String::class -> map { JexlString(it as String) } + Int::class -> map { JexlInteger(it as Int) } + Double::class -> map { JexlDouble(it as Double) } + Float::class -> map { JexlDouble((it as Float).toDouble()) } + Boolean::class -> map { JexlBoolean(it as Boolean) } + else -> throw UnsupportedOperationException("Can't convert type " + T::class + " to Jexl") + } + + return JexlArray(values) +} + +fun String.toJexl(): JexlString = JexlString(this) +fun Int.toJexl(): JexlInteger = JexlInteger(this) +fun Double.toJexl(): JexlDouble = JexlDouble(this) +fun Float.toJexl(): JexlDouble = JexlDouble(this.toDouble()) +fun Boolean.toJexl(): JexlBoolean = JexlBoolean(this) diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/grammar/Grammar.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/grammar/Grammar.kt new file mode 100644 index 0000000000..eb61970c96 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/grammar/Grammar.kt @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.grammar + +import mozilla.components.lib.jexl.evaluator.EvaluatorException +import mozilla.components.lib.jexl.lexer.Token +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlBoolean +import mozilla.components.lib.jexl.value.JexlDouble +import mozilla.components.lib.jexl.value.JexlInteger +import mozilla.components.lib.jexl.value.JexlString +import mozilla.components.lib.jexl.value.JexlValue +import kotlin.math.floor + +/** + * Grammar of the JEXL language. + * + * Note that changes here may require a change in the Lexer or Parser. + */ +@Suppress("MagicNumber") // Operator precedence uses numbers and I do not see the need for constants.. +class Grammar { + val elements: Map<String, GrammarElement> = mapOf( + "." to GrammarElement(Token.Type.DOT), + "[" to GrammarElement(Token.Type.OPEN_BRACKET), + "]" to GrammarElement(Token.Type.CLOSE_BRACKET), + "|" to GrammarElement(Token.Type.PIPE), + "{" to GrammarElement(Token.Type.OPEN_CURL), + "}" to GrammarElement(Token.Type.CLOSE_CURL), + ":" to GrammarElement(Token.Type.COLON), + "," to GrammarElement(Token.Type.COMMA), + "(" to GrammarElement(Token.Type.OPEN_PAREN), + ")" to GrammarElement(Token.Type.CLOSE_PAREN), + "?" to GrammarElement(Token.Type.QUESTION), + "+" to GrammarElement( + Token.Type.BINARY_OP, + 30, + ) { left, right -> left + right }, + "-" to GrammarElement(Token.Type.BINARY_OP, 30), + "*" to GrammarElement( + Token.Type.BINARY_OP, + 40, + ) { left, right -> left * right }, + "/" to GrammarElement(Token.Type.BINARY_OP, 40) { left, right -> + left / right + }, + "//" to GrammarElement( + Token.Type.BINARY_OP, + 40, + ) { left, right -> + when (val result = left.div(right)) { + is JexlInteger -> result + is JexlDouble -> JexlInteger( + floor( + result.value, + ).toInt(), + ) + else -> throw EvaluatorException("Cannot floor type: " + result::class) + } + }, + "%" to GrammarElement(Token.Type.BINARY_OP, 50), + "^" to GrammarElement(Token.Type.BINARY_OP, 50), + "==" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + JexlBoolean(left == right) + }, + "!=" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + JexlBoolean(left != right) + }, + ">" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + JexlBoolean(left > right) + }, + ">=" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + JexlBoolean(left >= right) + }, + "<" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + JexlBoolean(left < right) + }, + "<=" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + JexlBoolean(left <= right) + }, + "&&" to GrammarElement( + Token.Type.BINARY_OP, + 10, + ) { left, right -> + JexlBoolean(left.toBoolean() && right.toBoolean()) + }, + "||" to GrammarElement( + Token.Type.BINARY_OP, + 10, + ) { left, right -> + JexlBoolean(left.toBoolean() || right.toBoolean()) + }, + "in" to GrammarElement( + Token.Type.BINARY_OP, + 20, + ) { left, right -> + when { + left is JexlString -> JexlBoolean( + right.toString().contains(left.value), + ) + right is JexlArray -> JexlBoolean( + right.value.contains(left), + ) + else -> throw EvaluatorException( + "Operator 'in' not applicable to " + left::class + " and " + right::class, + ) + } + }, + "!" to GrammarElement( + Token.Type.UNARY_OP, + Int.MAX_VALUE, + ) { _, right -> + JexlBoolean(!right.toBoolean()) + }, + ) +} + +data class GrammarElement( + val type: Token.Type, + val precedence: Int = 0, + val evaluate: ((JexlValue, JexlValue) -> JexlValue)? = null, +) diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/Lexer.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/Lexer.kt new file mode 100644 index 0000000000..f2386c4255 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/Lexer.kt @@ -0,0 +1,223 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.lexer + +import mozilla.components.lib.jexl.grammar.Grammar +import mozilla.components.lib.jexl.grammar.GrammarElement + +internal class LexerException(message: String) : Exception(message) + +/** + * JEXL lexer for the lexical parsing of a JEXL string. + * + * Its responsibility is to identify the "parts of speech" of a Jexl expression, and tokenize and label each, but + * to do only the most minimal syntax checking; the only errors the Lexer should be concerned with are if it's unable + * to identify the utility of any of its tokens. Errors stemming from these tokens not being in a sensible + * configuration should be left for the Parser to handle. + */ +@Suppress("LargeClass") +internal class Lexer(private val grammar: Grammar) { + private val negateAfter = listOf( + Token.Type.BINARY_OP, + Token.Type.UNARY_OP, + Token.Type.OPEN_PAREN, + Token.Type.OPEN_BRACKET, + Token.Type.QUESTION, + Token.Type.COLON, + ) + + /** + * Splits the JEXL expression string into a list of tokens. + */ + @Suppress("ComplexMethod", "LongMethod") + @Throws(LexerException::class) + fun tokenize(raw: String): List<Token> { + val input = LexerInput(raw) + val tokens = mutableListOf<Token>() + + var negate = false + + while (!input.end()) { + if (negate && !input.character().isDigit() && !input.character().isWhitespace()) { + throw LexerException("Negating non digit: " + input.character()) + } + + when { + input.character() == '\'' -> tokens.add(readString(input, input.character())) + + input.character() == '"' -> tokens.add(readString(input, input.character())) + + input.character().isWhitespace() -> consumeWhiteSpaces(input) + + input.peekEquals("true") -> tokens.add( + Token( + Token.Type.LITERAL, + "true", + true, + ), + ) + + input.peekEquals("false") -> tokens.add( + Token( + Token.Type.LITERAL, + "false", + false, + ), + ) + + input.character() == '#' -> discardComment(input) + + input.character() == '-' -> { + val token = minusOrNegate(tokens) + if (token != null) { + tokens.add(token) + } else { + negate = true + } + input.proceed() + } + + isElement(input, grammar.elements) -> tokens.add(lastFoundElementToken!!) + + input.character().isLetter() || input.character() == '_' || input.character() == '$' -> + tokens.add(readIdentifier(input)) + + input.character().isDigit() -> { + tokens.add(readDigit(input, negate)) + negate = false + } + + else -> throw LexerException("Do not know how to proceed: " + input.character()) + } + } + + return tokens + } + + private var lastFoundElementToken: Token? = null + + @Suppress("ReturnCount") + private fun isElement(input: LexerInput, elements: Map<String, GrammarElement>): Boolean { + val max = elements.keys.map { it.length }.maxOrNull() ?: return false + + for (steps in max downTo 1) { + val candidate = input.peekRange(steps) + if (elements.containsKey(candidate)) { + if (candidate.last().isLetter() && input.peek(steps).isLetter()) { + return false + } + + val element = elements[candidate]!! + lastFoundElementToken = Token(element.type, candidate, candidate) + input.proceed(candidate.length) + + return true + } + } + + return false + } + + private fun minusOrNegate(tokens: List<Token>): Token? { + if (tokens.isEmpty()) { + return null + } + + if (tokens.last().type in negateAfter) { + return null + } + + return Token(Token.Type.BINARY_OP, "-", "-") + } + + private fun discardComment(input: LexerInput) { + while (!input.end() && input.character() != '\n') { + input.proceed() + } + } + + private fun consumeWhiteSpaces(input: LexerInput) { + while (!input.end() && input.character().isWhitespace()) { + input.proceed() + } + } + + private fun readString(input: LexerInput, quote: Char): Token { + input.mark() + input.proceed() + + while (!input.end()) { + // Very simple escaping implementation. + if (input.character() == quote && input.previous() != '\\') { + break + } + + input.proceed() + } + + input.proceed() + + val raw = input.emit() + + if (raw.last() != quote) { + throw LexerException("String literal not closed") + } + + val value = raw.substring(1, raw.length - 1) + .replace("\\" + quote, quote.toString()) + .replace("\\\\", "\\") + + return Token(Token.Type.LITERAL, raw, value) + } + + private fun readIdentifier(input: LexerInput): Token { + input.mark() + + while (!input.end()) { + if (!input.character().isLetterOrDigit() && input.character() != '_' && input.character() != '$') { + break + } + + input.proceed() + } + + val raw = input.emit() + + return Token(Token.Type.IDENTIFIER, raw, raw) + } + + @Suppress("ComplexMethod") + private fun readDigit(input: LexerInput, negate: Boolean): Token { + input.mark() + + var readDot = false + + while (!input.end()) { + if (!input.character().isDigit() && input.character() != '.') { + break + } else if (input.character() == '.' && readDot) { + break + } else if (input.character() == '.') { + readDot = true + } + + input.proceed() + } + + val raw = if (negate) { + "-${input.emit()}" + } else { + input.emit() + } + + val value: Any = if (raw.contains(".")) { + raw.toDouble() + } else { + raw.toInt() + } + + return Token(Token.Type.LITERAL, raw, value) + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/LexerInput.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/LexerInput.kt new file mode 100644 index 0000000000..f7d946e81a --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/LexerInput.kt @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.lexer + +/** + * Helper class for reading a string character by character with the ability to "peek" at upcoming characters. + */ +internal class LexerInput(private val value: String) { + private var position: Int = 0 + private var mark: Int = 0 + + /** + * Marks the current position in the input. + */ + fun mark() { + mark = position + } + + /** + * Emits the string from the marked position to the current position. + */ + fun emit(): String { + return value.substring(mark, position) + } + + /** + * Move the current position [steps] steps ahread. + */ + fun proceed(steps: Int = 1) { + position += steps + } + + /** + * Returns true if the string starting as the current position equals [candidate]. + */ + fun peekEquals(candidate: String): Boolean { + if (position + candidate.length > value.length) { + return false + } + + for (i in 0 until candidate.length) { + if (candidate[i] != value[position + i]) { + return false + } + } + + position += candidate.length + + return true + } + + /** + * Returns the string from the current position to [steps] ahead without moving the current position. + */ + fun peekRange(steps: Int): String { + if (position + steps > value.length) { + return "" + } + + return value.substring(position, position + steps) + } + + /** + * Returns the character at the current position + */ + fun character(): Char = value[position] + + /** + * Returns true if every character from the input has been read. + */ + fun end() = position == value.length + + /** + * Returns the character [steps] steps ahead. + */ + fun peek(steps: Int): Char = + if (position + steps == value.length) ' ' else value[position + steps] + + /** + * Returns the previous character. + */ + fun previous(): Char = if (position == 0) ' ' else value[position - 1] +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/Token.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/Token.kt new file mode 100644 index 0000000000..2c250a1ef3 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/lexer/Token.kt @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.lexer + +/** + * A token emitted by the [Lexer]. + */ +data class Token( + val type: Type, + val raw: String, + val value: Any, +) { + enum class Type { + LITERAL, + IDENTIFIER, + DOT, + OPEN_BRACKET, + CLOSE_BRACKET, + PIPE, + OPEN_CURL, + CLOSE_CURL, + COLON, + COMMA, + OPEN_PAREN, + CLOSE_PAREN, + QUESTION, + BINARY_OP, + UNARY_OP, + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/parser/Parser.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/parser/Parser.kt new file mode 100644 index 0000000000..0dbc47d002 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/parser/Parser.kt @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.parser + +import mozilla.components.lib.jexl.ast.AstNode +import mozilla.components.lib.jexl.ast.BinaryExpression +import mozilla.components.lib.jexl.ast.BranchNode +import mozilla.components.lib.jexl.ast.Identifier +import mozilla.components.lib.jexl.ast.Literal +import mozilla.components.lib.jexl.ast.OperatorNode +import mozilla.components.lib.jexl.ast.UnaryExpression +import mozilla.components.lib.jexl.grammar.Grammar +import mozilla.components.lib.jexl.lexer.Token + +/** + * JEXL parser. + * + * Takes a list of tokens from the lexer and transforms them into an abstract syntax tree (AST). + */ +internal class Parser( + internal val grammar: Grammar, + private val stopMap: Map<Token.Type, State> = mapOf(), +) { + private var state: State = State.EXPECT_OPERAND + internal var tree: AstNode? = null + internal var cursor: AstNode? = null + + internal var subParser: Parser? = null + private var parentStop: Boolean = false + internal var currentObjectKey: String? = null + + internal var nextIdentEncapsulate: Boolean = false + internal var nextIdentRelative: Boolean = false + internal var relative: Boolean = false + + @Synchronized + @Throws(ParserException::class) + fun parse(tokens: List<Token>): AstNode? { + parseTokens(tokens) + + return complete() + } + + private fun complete(): AstNode? { + if (cursor != null && !stateMachine[state]!!.completable) { + throw ParserException("Unexpected end of expression") + } + + if (subParser != null) { + endSubExpression() + } + + state = State.COMPLETE + + return if (cursor != null) { + tree + } else { + null + } + } + + private fun parseTokens(tokens: List<Token>) { + tokens.forEach { parseToken(it) } + } + + @Suppress("ComplexMethod", "LongMethod", "ThrowsCount") + private fun parseToken(token: Token): State? { + if (state == State.COMPLETE) { + throw ParserException("Token after parsing completed") + } + + val stateMap = stateMachine[state] + ?: throw ParserException("Can't continue from state: $state") + + if (stateMap.subHandler != null) { + // Use a sub handler for this state + if (subParser == null) { + startSubExpression() + } + val stopState = subParser!!.parseToken(token) + if (stopState != null) { + endSubExpression() + if (parentStop) { + return stopState + } + state = stopState + } + } else if (stateMap.map.containsKey(token.type)) { + val nextState = stateMap.map.getValue(token.type) + + if (nextState.handler != null) { + // Use handler for this transition + nextState.handler.invoke(this, token) + } else { + // Use generic handler for this token type (if it exists) + val handler = handlers[token.type] + handler?.invoke(this, token) + } + + nextState.state?.let { state = it } + } else if (stopMap.containsKey(token.type)) { + return stopMap.getValue(token.type) + } else { + throw ParserException("Token ${token.raw} (${token.type}) unexpected in state $state") + } + + return null + } + + internal fun placeAtCursor(node: AstNode) { + if (cursor == null) { + tree = node + } else { + cursor?.let { cursor -> + if (cursor is BranchNode) { + cursor.right = node + } + } + node.parent = cursor + } + + cursor = node + } + + internal fun placeBeforeCursor(node: AstNode) { + cursor = cursor!!.parent + placeAtCursor(node) + } + + private fun startSubExpression() { + var endStates = stateMachine[state]!!.endStates + if (endStates.isEmpty()) { + parentStop = true + endStates = stopMap + } + this.subParser = Parser(grammar, endStates) + } + + private fun endSubExpression() { + val stateMap = stateMachine[state]!! + val subHandler = stateMap.subHandler!! + val subParser = this.subParser!! + val node = subParser.complete() + + subHandler.invoke(this, node) + this.subParser = null + } +} + +class ParserException(message: String) : Exception(message) + +internal class StateMap( + val map: Map<Token.Type, NextState> = mapOf(), + val completable: Boolean = false, + val subHandler: ((Parser, AstNode?) -> Unit)? = null, + val endStates: Map<Token.Type, State> = mapOf(), +) + +internal enum class State { + EXPECT_OPERAND, + EXPECT_BIN_OP, + IDENTIFIER, + SUB_EXPRESSION, + EXPECT_OBJECT_KEY, + TRAVERSE, + ARRAY_VALUE, + EXPECT_TRANSFORM, + TERNARY_MID, + TERNARY_END, + COMPLETE, + POST_TRANSFORM, + EXPECT_KEY_VALUE_SEPARATOR, + OBJECT_VALUE, + ARGUMENT_VALUE, + FILTER, + POST_TRANSFORM_ARGUMENTS, +} + +internal class NextState( + val state: State? = null, + val handler: ((Parser, Token) -> Unit)? = null, +) + +internal val handlers: Map<Token.Type, (Parser, Token) -> Unit> = mapOf( + Token.Type.LITERAL to { parser, token -> + parser.placeAtCursor( + Literal(token.value), + ) + }, + + Token.Type.BINARY_OP to { parser, token -> + val precedence = parser.grammar.elements[token.value]?.precedence ?: 0 + var parent = parser.cursor!!.parent + + var operator = (parent as? OperatorNode)?.operator + + while (operator != null && + parser.grammar.elements[operator]!!.precedence > precedence + ) { + parser.cursor = parent + parent = parent?.parent + operator = (parent as? OperatorNode)?.operator + } + + val node = BinaryExpression( + left = parser.cursor, + operator = token.value.toString(), + ) + + parser.cursor!!.parent = node + parser.cursor = parent + parser.placeAtCursor(node) + }, + + Token.Type.IDENTIFIER to { parser, token -> + val node = Identifier(token.value) + + if (parser.nextIdentEncapsulate) { + node.from = parser.cursor + parser.placeBeforeCursor(node) + parser.nextIdentEncapsulate = false + } else { + if (parser.nextIdentRelative) { + node.relative = true + } + parser.placeAtCursor(node) + } + }, + + Token.Type.UNARY_OP to { parser, token -> + val node = UnaryExpression( + operator = token.value.toString(), + ) + parser.placeAtCursor(node) + }, + + Token.Type.DOT to { parser, _ -> + val cursor = parser.cursor + + parser.nextIdentEncapsulate = cursor != null && + (cursor !is BinaryExpression || cursor.right != null) && + cursor !is UnaryExpression + + parser.nextIdentRelative = cursor == null || !parser.nextIdentEncapsulate + + if (parser.nextIdentRelative) { + parser.relative = true + } + }, +) diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/parser/StateMachine.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/parser/StateMachine.kt new file mode 100644 index 0000000000..348fb537ca --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/parser/StateMachine.kt @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.parser + +import mozilla.components.lib.jexl.ast.ArrayLiteral +import mozilla.components.lib.jexl.ast.AstNode +import mozilla.components.lib.jexl.ast.ConditionalExpression +import mozilla.components.lib.jexl.ast.FilterExpression +import mozilla.components.lib.jexl.ast.ObjectLiteral +import mozilla.components.lib.jexl.ast.Transformation +import mozilla.components.lib.jexl.lexer.Token + +internal val stateMachine: Map<State, StateMap> = mapOf( + State.EXPECT_OPERAND to StateMap( + mapOf( + Token.Type.LITERAL to NextState(State.EXPECT_BIN_OP), + Token.Type.IDENTIFIER to NextState( + State.IDENTIFIER, + ), + Token.Type.UNARY_OP to NextState(), + Token.Type.OPEN_PAREN to NextState( + State.SUB_EXPRESSION, + ), + Token.Type.OPEN_CURL to NextState( + State.EXPECT_OBJECT_KEY, + ::objectStart, + ), + Token.Type.DOT to NextState(State.TRAVERSE), + Token.Type.OPEN_BRACKET to NextState( + State.ARRAY_VALUE, + ::arrayStart, + ), + ), + ), + State.EXPECT_BIN_OP to StateMap( + mapOf( + Token.Type.BINARY_OP to NextState( + State.EXPECT_OPERAND, + ), + Token.Type.PIPE to NextState(State.EXPECT_TRANSFORM), + Token.Type.DOT to NextState(State.TRAVERSE), + Token.Type.QUESTION to NextState( + State.TERNARY_MID, + ::ternaryStart, + ), + ), + completable = true, + ), + State.EXPECT_TRANSFORM to StateMap( + mapOf( + Token.Type.IDENTIFIER to NextState( + State.POST_TRANSFORM, + ::transform, + ), + ), + ), + State.EXPECT_OBJECT_KEY to StateMap( + mapOf( + Token.Type.IDENTIFIER to NextState( + State.EXPECT_KEY_VALUE_SEPARATOR, + ::objectKey, + ), + Token.Type.CLOSE_CURL to NextState( + State.EXPECT_BIN_OP, + ), + ), + ), + State.EXPECT_KEY_VALUE_SEPARATOR to StateMap( + mapOf( + Token.Type.COLON to NextState(State.OBJECT_VALUE), + ), + ), + State.POST_TRANSFORM to StateMap( + mapOf( + Token.Type.OPEN_PAREN to NextState( + State.ARGUMENT_VALUE, + ), + Token.Type.BINARY_OP to NextState( + State.EXPECT_OPERAND, + ), + Token.Type.DOT to NextState(State.TRAVERSE), + Token.Type.OPEN_BRACKET to NextState( + State.FILTER, + ), + Token.Type.PIPE to NextState(State.EXPECT_TRANSFORM), + ), + completable = true, + ), + State.POST_TRANSFORM_ARGUMENTS to StateMap( + mapOf( + Token.Type.BINARY_OP to NextState( + State.EXPECT_OPERAND, + ), + Token.Type.DOT to NextState(State.TRAVERSE), + Token.Type.OPEN_BRACKET to NextState( + State.FILTER, + ), + Token.Type.PIPE to NextState(State.EXPECT_TRANSFORM), + ), + completable = true, + ), + State.IDENTIFIER to StateMap( + mapOf( + Token.Type.BINARY_OP to NextState( + State.EXPECT_OPERAND, + ), + Token.Type.DOT to NextState(State.TRAVERSE), + Token.Type.OPEN_BRACKET to NextState( + State.FILTER, + ), + Token.Type.PIPE to NextState(State.EXPECT_TRANSFORM), + Token.Type.QUESTION to NextState( + State.TERNARY_MID, + ::ternaryStart, + ), + ), + completable = true, + ), + State.TRAVERSE to StateMap( + mapOf( + Token.Type.IDENTIFIER to NextState( + State.IDENTIFIER, + ), + ), + ), + State.FILTER to StateMap( + subHandler = { parser, node -> + val expressionNode = FilterExpression( + expression = node, + relative = parser.subParser!!.relative, + subject = parser.cursor, + ) + parser.placeBeforeCursor(expressionNode) + }, + endStates = mapOf( + Token.Type.CLOSE_BRACKET to State.IDENTIFIER, + ), + ), + State.SUB_EXPRESSION to StateMap( + subHandler = { parser, node -> + parser.placeAtCursor(node!!) + }, + endStates = mapOf( + Token.Type.CLOSE_PAREN to State.EXPECT_BIN_OP, + ), + ), + State.ARGUMENT_VALUE to StateMap( + subHandler = { parser, node -> + val cursor = parser.cursor!! as Transformation + cursor.arguments.add(node!!) + }, + endStates = mapOf( + Token.Type.COMMA to State.ARGUMENT_VALUE, + Token.Type.CLOSE_PAREN to State.EXPECT_BIN_OP, + ), + ), + State.OBJECT_VALUE to StateMap( + subHandler = { parser, node -> + val cursor = parser.cursor as ObjectLiteral + val properties = cursor.properties as MutableMap<String, AstNode> + + properties[parser.currentObjectKey!!] = node!! + }, + endStates = mapOf( + Token.Type.COMMA to State.EXPECT_OBJECT_KEY, + Token.Type.CLOSE_CURL to State.EXPECT_BIN_OP, + ), + ), + State.ARRAY_VALUE to StateMap( + subHandler = { parser, node -> + if (node != null) { + (parser.cursor!! as ArrayLiteral).values.add(node) + } + }, + endStates = mapOf( + Token.Type.COMMA to State.ARRAY_VALUE, + Token.Type.CLOSE_BRACKET to State.EXPECT_BIN_OP, + ), + ), + State.TERNARY_MID to StateMap( + subHandler = { parser, node -> + val cursor = parser.cursor!! as ConditionalExpression + cursor.consequent = node + }, + endStates = mapOf( + Token.Type.COLON to State.TERNARY_END, + ), + ), + State.TERNARY_END to StateMap( + subHandler = { parser, node -> + val cursor = parser.cursor!! as ConditionalExpression + cursor.alternate = node + }, + completable = true, + ), +) + +private fun objectStart(parser: Parser, @Suppress("UNUSED_PARAMETER") token: Token) { + val node = ObjectLiteral( + properties = mutableMapOf(), + ) + parser.placeAtCursor(node) +} + +private fun objectKey(parser: Parser, token: Token) { + parser.currentObjectKey = token.value.toString() +} + +private fun arrayStart(parser: Parser, @Suppress("UNUSED_PARAMETER") token: Token) { + val node = ArrayLiteral() + parser.placeAtCursor(node) +} + +private fun transform(parser: Parser, token: Token) { + val node = Transformation( + name = token.value.toString(), + subject = parser.cursor, + ) + parser.placeBeforeCursor(node) +} + +private fun ternaryStart(parser: Parser, @Suppress("UNUSED_PARAMETER") token: Token) { + val node = ConditionalExpression( + test = parser.tree, + ) + parser.tree = node + parser.cursor = node +} diff --git a/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/value/JexlValue.kt b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/value/JexlValue.kt new file mode 100644 index 0000000000..0ea67a46c8 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/main/java/mozilla/components/lib/jexl/value/JexlValue.kt @@ -0,0 +1,307 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.value + +import mozilla.components.lib.jexl.evaluator.EvaluatorException + +/** + * A JEXL value type. + */ +sealed class JexlValue { + abstract val value: Any + + abstract operator fun plus(other: JexlValue): JexlValue + abstract operator fun times(other: JexlValue): JexlValue + abstract operator fun div(other: JexlValue): JexlValue + abstract operator fun compareTo(other: JexlValue): Int + + abstract fun toBoolean(): Boolean +} + +/** + * JEXL Integer type. + */ +class JexlInteger(override val value: Int) : JexlValue() { + override fun div(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlInteger(value / other.value) + is JexlDouble -> JexlDouble(value / other.value) + else -> throw EvaluatorException("Can't divide by ${other::class}") + } + } + + override fun times(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlInteger(value * other.value) + is JexlDouble -> JexlDouble(value * other.value) + is JexlBoolean -> JexlInteger(value * other.toInt()) + else -> throw EvaluatorException("Can't multiply with ${other::class}") + } + } + + override fun plus(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlInteger(value + other.value) + is JexlDouble -> JexlDouble(value + other.value) + is JexlString -> JexlString(value.toString() + other.value) + is JexlBoolean -> JexlInteger(value + (other.toInt())) + else -> throw EvaluatorException("Can't add ${other::class}") + } + } + + override fun compareTo(other: JexlValue): Int { + return when (other) { + is JexlInteger -> value.compareTo(other.value) + is JexlDouble -> value.compareTo(other.value) + else -> throw EvaluatorException("Can't compare ${other::class}") + } + } + + override fun equals(other: Any?): Boolean { + return when (other) { + is JexlInteger -> value == other.value + is JexlDouble -> value.toDouble() == other.value + else -> false + } + } + + override fun toBoolean(): Boolean = value != 0 + + override fun toString() = value.toString() + + override fun hashCode() = value.hashCode() +} + +/** + * JEXL Double type. + */ +class JexlDouble(override val value: Double) : JexlValue() { + override fun div(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlDouble(value / other.value) + is JexlDouble -> JexlDouble(value / other.value) + else -> throw EvaluatorException("Can't divide by ${other::class}") + } + } + + override fun times(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlDouble(value * other.value) + is JexlDouble -> JexlDouble(value * other.value) + is JexlBoolean -> JexlDouble(value * other.toInt()) + else -> throw EvaluatorException("Can't multiply with ${other::class}") + } + } + + override fun plus(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlDouble(value + other.value) + is JexlDouble -> JexlDouble(value + other.value) + is JexlString -> JexlString(value.toString() + other.value) + is JexlBoolean -> JexlDouble(value + (other.toInt())) + else -> throw EvaluatorException("Can't add ${other::class}") + } + } + + override fun compareTo(other: JexlValue): Int { + return when (other) { + is JexlInteger -> value.compareTo(other.value) + is JexlDouble -> value.compareTo(other.value) + else -> throw EvaluatorException("Can't compare ${other::class}") + } + } + + override fun toBoolean(): Boolean = value != 0.0 + + override fun equals(other: Any?): Boolean { + return when (other) { + is JexlDouble -> value == other.value + is JexlInteger -> { + value == other.value.toDouble() + } + else -> false + } + } + + override fun toString() = value.toString() + + override fun hashCode() = value.hashCode() +} + +/** + * JEXL Boolean type. + */ +class JexlBoolean(override val value: Boolean) : JexlValue() { + override fun div(other: JexlValue): JexlValue { + throw EvaluatorException("Can't divide boolean") + } + + override fun times(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlInteger(toInt() * other.value) + is JexlDouble -> JexlDouble(toInt() * other.value) + is JexlBoolean -> JexlInteger(toInt() * other.toInt()) + else -> throw EvaluatorException("Can't multiply with ${other::class}") + } + } + + override fun plus(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlInteger((toInt()) + other.value) + is JexlDouble -> JexlDouble((toInt()) + other.value) + is JexlString -> JexlString(value.toString() + other.value) + is JexlBoolean -> JexlInteger((toInt()) + (other.toInt())) + else -> throw EvaluatorException("Can't add ${other::class}") + } + } + + override fun compareTo(other: JexlValue): Int { + throw EvaluatorException("Can't compare ${other::class}") + } + + fun toInt(): Int = if (value) 1 else 0 + + override fun equals(other: Any?) = other is JexlBoolean && value == other.value + + override fun toBoolean() = value + + override fun toString() = value.toString() + + override fun hashCode() = value.hashCode() +} + +/** + * JEXL String type. + */ +class JexlString(override val value: String) : JexlValue() { + override fun div(other: JexlValue): JexlValue { + throw EvaluatorException("Can't divide string") + } + + override fun times(other: JexlValue): JexlValue { + throw EvaluatorException("Can't multiply strings") + } + + override fun plus(other: JexlValue): JexlValue { + return when (other) { + is JexlInteger -> JexlString(value + other.value) + is JexlDouble -> JexlString(value + other.value) + is JexlString -> JexlString(value + other.value) + is JexlBoolean -> JexlString(value + (if (other.value) 1 else 0)) + else -> throw EvaluatorException("Can't add ${other::class}") + } + } + + override fun compareTo(other: JexlValue): Int { + throw EvaluatorException("Can't compare ${other::class}") + } + + override fun equals(other: Any?): Boolean { + return other is JexlString && value == other.value + } + + override fun toBoolean(): Boolean { + return value.isNotEmpty() + } + + override fun toString(): String { + return value + } + + override fun hashCode(): Int { + return value.hashCode() + } +} + +/** + * JEXL Array type. + */ +class JexlArray( + override val value: List<JexlValue>, +) : JexlValue() { + constructor(vararg elements: JexlValue) : this(elements.toList()) + + override fun div(other: JexlValue): JexlValue = throw EvaluatorException("Can't divide array") + + override fun times(other: JexlValue): JexlValue = throw EvaluatorException("Can't multiply arrays") + + override fun plus(other: JexlValue): JexlValue = throw EvaluatorException("Can't add arrays") + + override fun compareTo(other: JexlValue): Int = throw EvaluatorException("Can't compare ${other::class}") + + override fun equals(other: Any?) = other is JexlArray && value == other.value + + override fun toBoolean(): Boolean = throw EvaluatorException("Can't convert array to boolean") + + override fun toString(): String = value.toString() + + override fun hashCode(): Int = value.hashCode() +} + +/** + * JEXL Object type. + */ +class JexlObject( + override val value: Map<String, JexlValue>, +) : JexlValue() { + constructor(vararg pairs: Pair<String, JexlValue>) : this(pairs.toMap()) + + override fun div(other: JexlValue): JexlValue { + throw EvaluatorException("Can't divide object") + } + + override fun times(other: JexlValue): JexlValue { + throw EvaluatorException("Can't multiply objects") + } + + override fun plus(other: JexlValue): JexlValue { + throw EvaluatorException("Can't add objects") + } + + override fun compareTo(other: JexlValue): Int { + throw EvaluatorException("Can't compare ${other::class}") + } + + override fun equals(other: Any?): Boolean { + return other is JexlObject && value == other.value + } + + override fun toBoolean(): Boolean { + throw EvaluatorException("Can't convert object to boolean") + } + + override fun toString(): String { + return value.toString() + } + + override fun hashCode(): Int { + return value.hashCode() + } +} + +/** + * JEXL undefined type. + */ +class JexlUndefined : JexlValue() { + override val value = Any() + + override fun plus(other: JexlValue): JexlValue { + return this + } + + override fun times(other: JexlValue): JexlValue = throw EvaluatorException("Can't multiply undefined values") + + override fun div(other: JexlValue): JexlValue = throw EvaluatorException("Can't divide undefined values") + + override fun compareTo(other: JexlValue) = if (other is JexlUndefined) 0 else 1 + + override fun toBoolean(): Boolean = throw EvaluatorException("Can't convert undefined to boolean") + + override fun toString() = "<undefined>" + + override fun equals(other: Any?) = other is JexlUndefined + + override fun hashCode(): Int = 7 +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/JexlTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/JexlTest.kt new file mode 100644 index 0000000000..b9f04a04a9 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/JexlTest.kt @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl + +import mozilla.components.lib.jexl.evaluator.JexlContext +import mozilla.components.lib.jexl.ext.toJexl +import mozilla.components.lib.jexl.ext.toJexlArray +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class JexlTest { + + @Test + fun `Should evaluate expressions`() { + val jexl = Jexl() + + val result = jexl.evaluate("75 > 42") + assertEquals(true, result.value) + } + + @Test + fun `Should evaluate boolean expressions`() { + val jexl = Jexl() + + val result = jexl.evaluateBooleanExpression("42 + 23 > 50", defaultValue = false) + + assertEquals(true, result) + } + + @Test + fun `Should apply transform`() { + val jexl = Jexl() + + jexl.addTransform("split") { value, arguments -> + value.toString().split(arguments.first().toString()).toJexlArray() + } + + jexl.addTransform("lower") { value, _ -> + value.toString().lowercase().toJexl() + } + + jexl.addTransform("last") { value, _ -> + (value as JexlArray).value.last() + } + + assertEquals( + "poovey".toJexl(), + jexl.evaluate(""""Pam Poovey"|lower|split(' ')|last"""), + ) + + assertEquals( + JexlArray("password".toJexl(), "guest".toJexl()), + jexl.evaluate(""""password==guest"|split('=' + '=')"""), + ) + } + + @Test + fun `Should use context`() { + val jexl = Jexl() + + val context = JexlContext( + "employees" to JexlArray( + JexlObject( + "first" to "Sterling".toJexl(), + "last" to "Archer".toJexl(), + "age" to 36.toJexl(), + ), + JexlObject( + "first" to "Malory".toJexl(), + "last" to "Archer".toJexl(), + "age" to 75.toJexl(), + ), + JexlObject( + "first" to "Malory".toJexl(), + "last" to "Archer".toJexl(), + "age" to 33.toJexl(), + ), + ), + ) + + assertEquals( + JexlArray( + JexlObject( + "first" to "Sterling".toJexl(), + "last" to "Archer".toJexl(), + "age" to 36.toJexl(), + ), + JexlObject( + "first" to "Malory".toJexl(), + "last" to "Archer".toJexl(), + "age" to 33.toJexl(), + ), + ), + jexl.evaluate("employees[.age >= 30 && .age < 40]", context), + ) + + assertEquals( + JexlArray( + JexlObject( + "first" to "Malory".toJexl(), + "last" to "Archer".toJexl(), + "age" to 33.toJexl(), + ), + ), + jexl.evaluate("employees[.age >= 30 && .age < 90][.age < 35]", context), + ) + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/LanguageTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/LanguageTest.kt new file mode 100644 index 0000000000..9fd693f816 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/LanguageTest.kt @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl + +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import kotlin.reflect.KClass + +/** + * Additional test cases that test various JEXL expressions to get a high test coverage for the lexer, parser and + * evaluator. + */ +class LanguageTest { + @Test + fun `Multiple dots in numeric expression should throw exception`() { + "27.42.21".evaluationThrows() + } + + @Test + fun `Negating a literal should throw exception`() { + "-employees".evaluationThrows() + } + + @Test + fun `Using non grammar character should throw exception`() { + "§".evaluationThrows() + } + + private fun String.evaluatesTo(expectedResult: Any) { + val jexl = Jexl() + val actualResult = jexl.evaluate(this) + + assertEquals(expectedResult, actualResult) + } + + private fun String.evaluationThrows() { + evaluationThrows(JexlException::class) + } + + private inline fun <reified T : Throwable> String.evaluationThrows(clazz: KClass<T>?) { + try { + evaluatesTo(Any()) + fail("Expected exception to be thrown: $clazz") + } catch (e: Throwable) { + if (e !is T) { + throw e + } + } + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/evaluator/EvaluatorTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/evaluator/EvaluatorTest.kt new file mode 100644 index 0000000000..490140bc2b --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/evaluator/EvaluatorTest.kt @@ -0,0 +1,375 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.evaluator + +import mozilla.components.lib.jexl.ast.AstNode +import mozilla.components.lib.jexl.grammar.Grammar +import mozilla.components.lib.jexl.lexer.Lexer +import mozilla.components.lib.jexl.parser.Parser +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlInteger +import mozilla.components.lib.jexl.value.JexlObject +import mozilla.components.lib.jexl.value.JexlString +import mozilla.components.lib.jexl.value.JexlUndefined +import mozilla.components.lib.jexl.value.JexlValue +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +class EvaluatorTest { + private lateinit var grammar: Grammar + + @Before + fun setUp() { + grammar = Grammar() + } + + @Test + fun `Should evaluate a literal`() { + assertExpressionYieldsResult("42", 42) + assertExpressionYieldsResult("2.0", 2.0) + + assertExpressionYieldsResult("true", true) + assertExpressionYieldsResult("false", false) + + assertExpressionYieldsResult("\"hello world\"", "hello world") + assertExpressionYieldsResult("'hello world'", "hello world") + } + + @Test + fun `Should evaluate an arithmetic expression`() { + assertExpressionYieldsResult( + "(2 + 3) * 4", + 20, + ) + } + + @Test + fun `Should evaluate a string concat`() { + assertExpressionYieldsResult( + """ + "Hello" + (4+4) + "Wo\"rld" + """.trimIndent(), + "Hello8Wo\"rld", + ) + } + + @Test + fun `Should evaluate a true comparison expression`() { + assertExpressionYieldsResult( + "2 > 1", + true, + ) + } + + @Test + fun `Should evaluate a false comparison expression`() { + assertExpressionYieldsResult( + "2 <= 1", + false, + ) + } + + @Test + fun `Should evaluate a complex expression`() { + assertExpressionYieldsResult( + "\"foo\" && 6 >= 6 && 0 + 1 && true", + true, + ) + } + + @Test + fun `Should evaluate an identifier chain`() { + val context = JexlContext( + "foo" to JexlObject( + "baz" to JexlObject( + "bar" to JexlString("tek"), + ), + ), + ) + + assertExpressionYieldsResult( + "foo.baz.bar", + "tek", + context = context, + ) + } + + @Test + fun `Should apply transforms`() { + val context = JexlContext( + "foo" to JexlInteger(10), + ) + + assertExpressionYieldsResult( + "foo|half + 3", + 8, + context = context, + transforms = mapOf( + "half" to { value, _ -> + value.div(JexlInteger(2)) + }, + ), + ) + } + + @Test + fun `Should filter arrays`() { + val context = JexlContext( + "foo" to JexlObject( + "bar" to JexlArray( + JexlObject("tek" to JexlString("hello")), + JexlObject("tek" to JexlString("baz")), + JexlObject("tok" to JexlString("baz")), + ), + ), + ) + + assertExpressionYieldsResult( + "foo.bar[.tek == \"baz\"]", + listOf(JexlObject("tek" to JexlString("baz"))), + context = context, + ) + } + + @Test + fun `Should assume array index 0 when traversing`() { + val context = JexlContext( + "foo" to JexlObject( + "bar" to JexlArray( + JexlObject( + "tek" to JexlObject( + "hello" to JexlString( + "world", + ), + ), + ), + JexlObject( + "tek" to JexlObject( + "hello" to JexlString( + "universe", + ), + ), + ), + ), + ), + ) + + assertExpressionYieldsResult( + "foo.bar.tek.hello", + "world", + context = context, + ) + } + + @Test + fun `Should make array elements addressable by index`() { + val context = JexlContext( + "foo" to JexlObject( + "bar" to JexlArray( + JexlObject("tek" to JexlString("tok")), + JexlObject("tek" to JexlString("baz")), + JexlObject("tek" to JexlString("foz")), + ), + ), + ) + + assertExpressionYieldsResult( + "foo.bar[1].tek", + "baz", + context = context, + ) + } + + @Test + fun `Should allow filters to select object properties`() { + val context = JexlContext( + "foo" to JexlObject( + "baz" to JexlObject( + "bar" to JexlString("tek"), + ), + ), + ) + + assertExpressionYieldsResult( + "foo[\"ba\" + \"z\"].bar", + "tek", + context = context, + ) + } + + @Test + fun `Should allow simple filters on undefined objects`() { + val context = JexlContext( + "foo" to JexlObject(), + ) + + assertExpressionYieldsResult( + "foo.bar[\"baz\"].tok", + JexlUndefined(), + context = context, + unpack = false, + ) + } + + @Test + fun `Should allow complex filters on undefined objects`() { + val context = JexlContext( + "foo" to JexlObject(), + ) + + assertExpressionYieldsResult( + "foo.bar[.size > 1].baz", + JexlUndefined(), + context = context, + unpack = false, + ) + } + + @Test(expected = EvaluatorException::class) + fun `Should throw when transform does not exist`() { + assertExpressionYieldsResult( + "\"hello\"|world", + "-- should throw", + ) + } + + @Test + fun `Should apply the DivFloor operator`() { + assertExpressionYieldsResult( + "7 // 2", + 3, + ) + } + + @Test + fun `Should evaluate an object literal`() { + assertExpressionYieldsResult( + "{foo: {bar: \"tek\"}}", + JexlObject( + "foo" to JexlObject( + "bar" to JexlString( + "tek", + ), + ), + ), + unpack = false, + ) + } + + @Test + fun `Should evaluate an empty object literal`() { + assertExpressionYieldsResult( + "{}", + emptyMap<String, JexlValue>(), + ) + } + + @Test + fun `Should evaluate a transform with multiple args`() { + assertExpressionYieldsResult( + """"foo"|concat("baz", "bar", "tek")""", + "foo: bazbartek", + transforms = mapOf( + "concat" to { value, arguments -> + value + JexlString(": ") + JexlString( + arguments.joinToString(""), + ) + }, + ), + ) + } + + @Test + fun `Should evaluate dot notation for object literals`() { + assertExpressionYieldsResult( + "{foo: \"bar\"}.foo", + "bar", + ) + } + + @Test + @Ignore("JavaScript properties are not implemented yet") + fun `Should allow access to literal properties`() { + assertExpressionYieldsResult( + "\"foo\".length", + 3, + ) + } + + @Test + fun `Should evaluate array literals`() { + assertExpressionYieldsResult( + "[\"foo\", 1+2]", + listOf( + JexlString("foo"), + JexlInteger(3), + ), + ) + } + + @Test + fun `Should apply the 'in' operator to strings`() { + assertExpressionYieldsResult(""""bar" in "foobartek"""", true) + assertExpressionYieldsResult(""""baz" in "foobartek"""", false) + } + + @Test + fun `Should apply the 'in' operator to arrays`() { + assertExpressionYieldsResult(""""bar" in ["foo","bar","tek"]""", true) + assertExpressionYieldsResult(""""baz" in ["foo","bar","tek"]""", false) + } + + @Test + fun `Should evaluate a conditional expression`() { + assertExpressionYieldsResult("\"foo\" ? 1 : 2", 1) + assertExpressionYieldsResult("\"\" ? 1 : 2", 2) + } + + @Test + fun `Should allow missing consequent in ternary`() { + assertExpressionYieldsResult(""""foo" ?: "bar"""", "foo") + } + + @Test + @Ignore("JavaScript properties are not implemented yet") + fun `Does not treat falsey properties as undefined`() { + assertExpressionYieldsResult("\"\".length", 0) + } + + @Test + fun `Should handle an expression with arbitrary whitespace`() { + assertExpressionYieldsResult("(\t2\n+\n3) *\n4\n\r\n", 20) + } + + private fun assertExpressionYieldsResult( + expression: String, + result: Any, + context: JexlContext = JexlContext(), + transforms: Map<String, Transform> = emptyMap(), + unpack: Boolean = true, + ) { + val tree = toTree(expression) + + println(tree) + + val evaluator = Evaluator(context, grammar, transforms) + val actual = evaluator.evaluate(tree) + + assertEquals(result, if (unpack) actual.value else actual) + } + + private fun toTree( + expression: String, + grammar: Grammar = Grammar(), + ): AstNode { + val lexer = Lexer(grammar) + val parser = Parser(grammar) + + return parser.parse(lexer.tokenize(expression)) + ?: throw AssertionError("Expression yielded null AST tree") + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/ext/JexlExtensionsTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/ext/JexlExtensionsTest.kt new file mode 100644 index 0000000000..0787d17ea1 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/ext/JexlExtensionsTest.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.ext + +import mozilla.components.lib.jexl.value.JexlArray +import mozilla.components.lib.jexl.value.JexlBoolean +import mozilla.components.lib.jexl.value.JexlDouble +import mozilla.components.lib.jexl.value.JexlInteger +import mozilla.components.lib.jexl.value.JexlString +import org.junit.Assert.assertEquals +import org.junit.Test +import java.lang.UnsupportedOperationException + +class JexlExtensionsTest { + @Test + fun `Simple types`() { + assertEquals("Hello", "Hello".toJexl().value) + assertEquals(23, 23.toJexl().value) + assertEquals(23.0, 23.0.toJexl().value, 0.00001) + assertEquals(1.0, 1.0f.toJexl().value, 0.00001) + assertEquals(0, 0.toJexl().value) + assertEquals(true, true.toJexl().value) + assertEquals(false, false.toJexl().value) + } + + @Test + fun `Arrays`() { + assertEquals( + JexlArray(JexlInteger(1), JexlInteger(2), JexlInteger(3)), + listOf(1, 2, 3).toJexlArray(), + ) + + assertEquals( + JexlArray(), + emptyList<String>().toJexlArray(), + ) + + assertEquals( + JexlArray(JexlString("Hello"), JexlString("World")), + listOf("Hello", "World").toJexlArray(), + ) + + assertEquals( + JexlArray(JexlDouble(1.0), JexlDouble(23.0)), + listOf(1.0, 23.0).toJexlArray(), + ) + + assertEquals( + JexlArray(JexlDouble(52.0), JexlDouble(-2.0)), + listOf(52.0f, -2f).toJexlArray(), + ) + + assertEquals( + JexlArray(JexlBoolean(false), JexlBoolean(true)), + listOf(false, true).toJexlArray(), + ) + } + + @Test(expected = UnsupportedOperationException::class) + fun `Unsupported array type`() { + listOf(Pair(1, 2), Pair(3, 4)).toJexlArray() + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/lexer/LexerTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/lexer/LexerTest.kt new file mode 100644 index 0000000000..fc8b518f0b --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/lexer/LexerTest.kt @@ -0,0 +1,483 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.lexer + +import mozilla.components.lib.jexl.grammar.Grammar +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class LexerTest { + private lateinit var lexer: Lexer + + @Before + fun setUp() { + lexer = Lexer(Grammar()) + } + + @Test + fun `should count a string as one element`() { + val expression = "\"foo\"" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.LITERAL, + "\"foo\"", + "foo", + ), + ), + ) + } + + @Test + fun `should support single-quote strings`() { + val expression = "'foo'" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "'foo'", "foo"), + ), + ) + } + + @Test + fun `should find multiple strings`() { + val expression = "\"foo\" 'bar' \"baz\"" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.LITERAL, + "\"foo\"", + "foo", + ), + Token( + Token.Type.LITERAL, + "'bar'", + "bar", + ), + Token( + Token.Type.LITERAL, + "\"baz\"", + "baz", + ), + ), + ) + } + + @Test + fun `should support escaping double-quotes`() { + val expression = "\"f\\\"oo\"" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.LITERAL, + "\"f\\\"oo\"", + "f\"oo", + ), + ), + ) + } + + @Test + fun `should support escaping single-quotes`() { + val expression = "'f\\'oo'" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.LITERAL, + "'f\\'oo'", + "f'oo", + ), + ), + ) + } + + @Test + fun `should count an identifier as one element`() { + val expression = "alpha12345" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.IDENTIFIER, + "alpha12345", + "alpha12345", + ), + ), + ) + } + + @Test + fun `should support boolean true`() { + val expression = "true" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "true", true), + ), + ) + } + + @Test + fun `should support boolean false`() { + val expression = "false" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "false", false), + ), + ) + } + + @Test + fun `should support comments`() { + val expression = "# This is a comment" + + assertExpressionYieldsTokens(expression, emptyList()) + } + + @Test + fun `should support comments after expressions`() { + val expression = "true false #This is a comment" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.LITERAL, "false", false), + ), + ) + } + + @Test + fun `should support operators`() { + val expression = "true + false - true + false++" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.BINARY_OP, "+", "+"), + Token( + Token.Type.LITERAL, + "false", + false, + ), + Token(Token.Type.BINARY_OP, "-", "-"), + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.BINARY_OP, "+", "+"), + Token( + Token.Type.LITERAL, + "false", + false, + ), + Token(Token.Type.BINARY_OP, "+", "+"), + Token(Token.Type.BINARY_OP, "+", "+"), + ), + ) + } + + @Test + fun `should support operators with two characters`() { + val expression = "true == false + true != false >= true > in false" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.BINARY_OP, "==", "=="), + Token( + Token.Type.LITERAL, + "false", + false, + ), + Token(Token.Type.BINARY_OP, "+", "+"), + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.BINARY_OP, "!=", "!="), + Token( + Token.Type.LITERAL, + "false", + false, + ), + Token(Token.Type.BINARY_OP, ">=", ">="), + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.BINARY_OP, ">", ">"), + Token(Token.Type.BINARY_OP, "in", "in"), + Token(Token.Type.LITERAL, "false", false), + ), + ) + } + + @Test + fun `should support numerics`() { + val expression = "1234 == 782" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "1234", 1234), + Token(Token.Type.BINARY_OP, "==", "=="), + Token(Token.Type.LITERAL, "782", 782), + ), + ) + } + + @Test + fun `should support negative numerics`() { + val expression = "-7.6 + (-20 * -1)" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "-7.6", -7.6), + Token(Token.Type.BINARY_OP, "+", "+"), + Token(Token.Type.OPEN_PAREN, "(", "("), + Token(Token.Type.LITERAL, "-20", -20), + Token(Token.Type.BINARY_OP, "*", "*"), + Token(Token.Type.LITERAL, "-1", -1), + Token(Token.Type.CLOSE_PAREN, ")", ")"), + ), + ) + } + + @Test + fun `should support floating point numerics`() { + val expression = "1.337 != 2.42" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.LITERAL, + "1.337", + 1.337, + ), + Token(Token.Type.BINARY_OP, "!=", "!="), + Token(Token.Type.LITERAL, "2.42", 2.42), + ), + ) + } + + @Test + fun `should support identifiers, numerics and operators`() { + val expression = "person.age == 12 && (person.hasJob == true || person.onVacation != false)" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.IDENTIFIER, + "person", + "person", + ), + Token(Token.Type.DOT, ".", "."), + Token( + Token.Type.IDENTIFIER, + "age", + "age", + ), + Token(Token.Type.BINARY_OP, "==", "=="), + Token(Token.Type.LITERAL, "12", 12), + Token(Token.Type.BINARY_OP, "&&", "&&"), + Token(Token.Type.OPEN_PAREN, "(", "("), + Token( + Token.Type.IDENTIFIER, + "person", + "person", + ), + Token(Token.Type.DOT, ".", "."), + Token( + Token.Type.IDENTIFIER, + "hasJob", + "hasJob", + ), + Token(Token.Type.BINARY_OP, "==", "=="), + Token(Token.Type.LITERAL, "true", true), + Token(Token.Type.BINARY_OP, "||", "||"), + Token( + Token.Type.IDENTIFIER, + "person", + "person", + ), + Token(Token.Type.DOT, ".", "."), + Token( + Token.Type.IDENTIFIER, + "onVacation", + "onVacation", + ), + Token(Token.Type.BINARY_OP, "!=", "!="), + Token( + Token.Type.LITERAL, + "false", + false, + ), + Token(Token.Type.CLOSE_PAREN, ")", ")"), + ), + ) + } + + @Test + fun `tokenize math expression`() { + val expression = "age * (3 - 1)" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.IDENTIFIER, + "age", + "age", + ), + Token(Token.Type.BINARY_OP, "*", "*"), + Token(Token.Type.OPEN_PAREN, "(", "("), + Token(Token.Type.LITERAL, "3", 3), + Token(Token.Type.BINARY_OP, "-", "-"), + Token(Token.Type.LITERAL, "1", 1), + Token(Token.Type.CLOSE_PAREN, ")", ")"), + ), + ) + } + + @Test + fun `should not split grammar elements out of transforms`() { + val expression = "inString" + + assertExpressionYieldsTokens( + expression, + listOf( + Token( + Token.Type.IDENTIFIER, + "inString", + "inString", + ), + ), + ) + } + + @Test + fun `should handle a complex mix of comments in single, multiline and value contexts`() { + val expression = """ + 6+x - -17.55*y #end comment + <= !foo.bar["baz\"foz"] # with space + && b=="not a #comment" # is a comment + # comment # 2nd comment + """.trimIndent() + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "6", 6), + Token(Token.Type.BINARY_OP, "+", "+"), + Token(Token.Type.IDENTIFIER, "x", "x"), + Token(Token.Type.BINARY_OP, "-", "-"), + Token( + Token.Type.LITERAL, + "-17.55", + -17.55, + ), + Token(Token.Type.BINARY_OP, "*", "*"), + Token(Token.Type.IDENTIFIER, "y", "y"), + Token(Token.Type.BINARY_OP, "<=", "<="), + Token(Token.Type.UNARY_OP, "!", "!"), + Token( + Token.Type.IDENTIFIER, + "foo", + "foo", + ), + Token(Token.Type.DOT, ".", "."), + Token( + Token.Type.IDENTIFIER, + "bar", + "bar", + ), + Token(Token.Type.OPEN_BRACKET, "[", "["), + Token( + Token.Type.LITERAL, + "\"baz\\\"foz\"", + "baz\"foz", + ), + Token( + Token.Type.CLOSE_BRACKET, + "]", + "]", + ), + Token(Token.Type.BINARY_OP, "&&", "&&"), + Token(Token.Type.IDENTIFIER, "b", "b"), + Token(Token.Type.BINARY_OP, "==", "=="), + Token( + Token.Type.LITERAL, + "\"not a #comment\"", + "not a #comment", + ), + ), + ) + } + + @Test + fun `should tokenize a full expression`() { + val expression = """6+x - -17.55*y<= !foo.bar["baz\\"foz"]""" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "6", 6), + Token(Token.Type.BINARY_OP, "+", "+"), + Token(Token.Type.IDENTIFIER, "x", "x"), + Token(Token.Type.BINARY_OP, "-", "-"), + Token(Token.Type.LITERAL, "-17.55", -17.55), + Token(Token.Type.BINARY_OP, "*", "*"), + Token(Token.Type.IDENTIFIER, "y", "y"), + Token(Token.Type.BINARY_OP, "<=", "<="), + Token(Token.Type.UNARY_OP, "!", "!"), + Token(Token.Type.IDENTIFIER, "foo", "foo"), + Token(Token.Type.DOT, ".", "."), + Token(Token.Type.IDENTIFIER, "bar", "bar"), + Token(Token.Type.OPEN_BRACKET, "[", "["), + Token(Token.Type.LITERAL, """"baz\\"foz"""", """baz\"foz"""), + Token(Token.Type.CLOSE_BRACKET, "]", "]"), + ), + ) + } + + @Test + fun `should consider minus to be negative appropriately`() { + val expression = "-1?-2:-3" + + assertExpressionYieldsTokens( + expression, + listOf( + Token(Token.Type.LITERAL, "-1", -1), + Token(Token.Type.QUESTION, "?", "?"), + Token(Token.Type.LITERAL, "-2", -2), + Token(Token.Type.COLON, ":", ":"), + Token(Token.Type.LITERAL, "-3", -3), + ), + ) + } + + private fun assertExpressionYieldsTokens(expression: String, tokens: List<Token>) { + val actual = lexer.tokenize(expression) + + println(actual) + + assertEquals(tokens.size, actual.size) + + for (i in 0 until tokens.size) { + assertEquals(tokens[i], actual[i]) + } + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/parser/ParserTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/parser/ParserTest.kt new file mode 100644 index 0000000000..0dbb07bc27 --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/parser/ParserTest.kt @@ -0,0 +1,506 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.parser + +import mozilla.components.lib.jexl.ast.ArrayLiteral +import mozilla.components.lib.jexl.ast.AstNode +import mozilla.components.lib.jexl.ast.BinaryExpression +import mozilla.components.lib.jexl.ast.ConditionalExpression +import mozilla.components.lib.jexl.ast.FilterExpression +import mozilla.components.lib.jexl.ast.Identifier +import mozilla.components.lib.jexl.ast.Literal +import mozilla.components.lib.jexl.ast.ObjectLiteral +import mozilla.components.lib.jexl.ast.Transformation +import mozilla.components.lib.jexl.ast.UnaryExpression +import mozilla.components.lib.jexl.grammar.Grammar +import mozilla.components.lib.jexl.lexer.Lexer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class ParserTest { + + @Test + fun `Should parse literal`() { + val expression = "42" + + assertExpressionYieldsTree( + expression, + Literal(42), + ) + } + + @Test + fun `Should parse math expression`() { + val expression = "42 + 23" + + assertExpressionYieldsTree( + expression, + BinaryExpression( + left = Literal(42), + right = Literal(23), + operator = "+", + ), + ) + } + + @Test(expected = ParserException::class) + fun `Should throw on incomplete expression`() { + val expression = "42 +" + parse(expression) + } + + @Test + fun `Should parse expression with identifier`() { + val expression = "age > 21" + + assertExpressionYieldsTree( + expression, + BinaryExpression( + operator = ">", + left = Identifier("age"), + right = Literal(21), + ), + ) + } + + @Test + fun `Should parse expression with sub expression`() { + val expression = "(age + 5) > 42" + + assertExpressionYieldsTree( + expression, + BinaryExpression( + operator = ">", + left = BinaryExpression( + operator = "+", + left = Identifier("age"), + right = Literal(5), + ), + right = Literal(42), + ), + ) + } + + @Test + fun `Should parse expression following operator precedence`() { + assertExpressionYieldsTree( + "5 + 7 * 2", + BinaryExpression( + operator = "+", + left = Literal(5), + right = BinaryExpression( + operator = "*", + left = Literal(7), + right = Literal(2), + ), + ), + ) + + assertExpressionYieldsTree( + "5 * 7 + 2", + BinaryExpression( + operator = "+", + left = BinaryExpression( + operator = "*", + left = Literal(5), + right = Literal(7), + ), + right = Literal(2), + ), + ) + } + + @Test + fun `Should handle encapsulation of subtree`() { + assertExpressionYieldsTree( + "2+3*4==5/6-7", + BinaryExpression( + operator = "==", + left = BinaryExpression( + operator = "+", + left = Literal(2), + right = BinaryExpression( + operator = "*", + left = Literal(3), + right = Literal(4), + ), + ), + right = BinaryExpression( + operator = "-", + left = BinaryExpression( + operator = "/", + left = Literal(5), + right = Literal(6), + ), + right = Literal(7), + ), + ), + ) + } + + @Test + fun `Should handle a unary operator`() { + assertExpressionYieldsTree( + "1*!!true-2", + BinaryExpression( + operator = "-", + left = BinaryExpression( + operator = "*", + left = Literal(1), + right = UnaryExpression( + operator = "!", + right = UnaryExpression( + operator = "!", + right = Literal(true), + ), + ), + ), + right = Literal(2), + ), + ) + } + + @Test + fun `Should handle nested subexpressions`() { + assertExpressionYieldsTree( + "(4*(2+3))/5", + BinaryExpression( + operator = "/", + left = BinaryExpression( + operator = "*", + left = Literal(4), + right = BinaryExpression( + operator = "+", + left = Literal(2), + right = Literal(3), + ), + ), + right = Literal(5), + ), + ) + } + + @Test + fun `Should handle whitespace in an expression`() { + assertExpressionYieldsTree( + "\t2\r\n+\n\r3\n\n", + BinaryExpression( + operator = "+", + left = Literal(2), + right = Literal(3), + ), + ) + } + + @Test + fun `Should handle object literals`() { + assertExpressionYieldsTree( + "{foo: \"bar\", tek: 1+2}", + ObjectLiteral( + "foo" to Literal("bar"), + "tek" to BinaryExpression( + operator = "+", + left = Literal(1), + right = Literal(2), + ), + ), + ) + } + + @Test + fun `Should handle nested object literals`() { + assertExpressionYieldsTree( + """{ + foo: { + bar: "tek", + baz: 42 + } + }""", + ObjectLiteral( + "foo" to ObjectLiteral( + "bar" to Literal("tek"), + "baz" to Literal(42), + ), + ), + ) + } + + @Test + fun `Should handle empty object literals`() { + assertExpressionYieldsTree( + "{}", + ObjectLiteral(), + ) + } + + @Test + fun `Should handle array literals`() { + assertExpressionYieldsTree( + "[\"foo\", 1+2]", + ArrayLiteral( + Literal("foo"), + BinaryExpression( + operator = "+", + left = Literal(1), + right = Literal(2), + ), + ), + ) + } + + @Test + fun `Should handle nested array literals`() { + assertExpressionYieldsTree( + "[\"foo\", [\"bar\", \"tek\"]]", + ArrayLiteral( + Literal("foo"), + ArrayLiteral( + Literal("bar"), + Literal("tek"), + ), + ), + ) + } + + @Test + fun `Should handle empty array literals`() { + assertExpressionYieldsTree( + "[]", + ArrayLiteral(), + ) + } + + @Test + fun `Should chain traversed identifiers`() { + assertExpressionYieldsTree( + "foo.bar.baz + 1", + BinaryExpression( + operator = "+", + left = Identifier( + "baz", + from = Identifier( + "bar", + from = Identifier("foo"), + ), + ), + right = Literal(1), + ), + ) + } + + @Test + fun `Should apply transforms and arguments`() { + assertExpressionYieldsTree( + "foo|tr1|tr2.baz|tr3({bar:\"tek\"})", + Transformation( + name = "tr3", + arguments = mutableListOf( + ObjectLiteral( + "bar" to Literal("tek"), + ), + ), + subject = Identifier( + value = "baz", + from = Transformation( + name = "tr2", + subject = Transformation( + name = "tr1", + subject = Identifier("foo"), + ), + ), + ), + ), + ) + } + + @Test + fun `Should handle multiple arguments in transforms`() { + assertExpressionYieldsTree( + "foo|bar(\"tek\", 5, true)", + Transformation( + name = "bar", + subject = Identifier("foo"), + arguments = mutableListOf( + Literal("tek"), + Literal(5), + Literal(true), + ), + ), + ) + } + + @Test + fun `Should apply filters to identifiers`() { + assertExpressionYieldsTree( + """foo[1][.bar[0] == "tek"].baz""", + Identifier( + "baz", + from = FilterExpression( + relative = true, + expression = BinaryExpression( + operator = "==", + left = FilterExpression( + relative = false, + expression = Literal(0), + subject = Identifier( + value = "bar", + relative = true, + ), + ), + right = Literal("tek"), + ), + subject = FilterExpression( + relative = false, + expression = Literal(1), + subject = Identifier("foo"), + ), + ), + ), + ) + } + + @Test + fun `Should allow dot notation for all operands`() { + assertExpressionYieldsTree( + "\"foo\".length + {foo: \"bar\"}.foo", + BinaryExpression( + operator = "+", + left = Identifier("length", from = Literal("foo")), + right = Identifier( + "foo", + from = ObjectLiteral( + "foo" to Literal("bar"), + ), + ), + ), + ) + } + + @Test + fun `Should allow dot notation on subexpressions`() { + assertExpressionYieldsTree( + "(\"foo\" + \"bar\").length", + Identifier( + "length", + from = BinaryExpression( + operator = "+", + left = Literal("foo"), + right = Literal("bar"), + ), + ), + ) + } + + @Test + fun `Should allow dot notation on arrays`() { + assertExpressionYieldsTree( + "[\"foo\", \"bar\"].length", + Identifier( + "length", + from = ArrayLiteral( + Literal("foo"), + Literal("bar"), + ), + ), + ) + } + + @Test + fun `Should handle a ternary expression`() { + assertExpressionYieldsTree( + "foo ? 1 : 0", + ConditionalExpression( + test = Identifier("foo"), + consequent = Literal(1), + alternate = Literal(0), + ), + ) + } + + @Test + fun `Should handle nested and grouped ternary expressions`() { + assertExpressionYieldsTree( + "foo ? (bar ? 1 : 2) : 3", + ConditionalExpression( + test = Identifier("foo"), + consequent = ConditionalExpression( + test = Identifier("bar"), + consequent = Literal(1), + alternate = Literal(2), + ), + alternate = Literal(3), + ), + ) + } + + @Test + fun `Should handle nested, non-grouped ternary expressions`() { + assertExpressionYieldsTree( + "foo ? bar ? 1 : 2 : 3", + ConditionalExpression( + test = Identifier("foo"), + consequent = ConditionalExpression( + test = Identifier("bar"), + consequent = Literal(1), + alternate = Literal(2), + ), + alternate = Literal(3), + ), + ) + } + + @Test + fun `Should handle ternary expression with objects`() { + assertExpressionYieldsTree( + "foo ? {bar: \"tek\"} : \"baz\"", + ConditionalExpression( + test = Identifier("foo"), + consequent = ObjectLiteral( + "bar" to Literal("tek"), + ), + alternate = Literal("baz"), + ), + ) + } + + @Test + fun `Should correctly balance a binary op between complex identifiers`() { + assertExpressionYieldsTree( + "a.b == c.d", + BinaryExpression( + operator = "==", + left = Identifier( + value = "b", + from = Identifier("a"), + ), + right = Identifier( + value = "d", + from = Identifier("c"), + ), + ), + ) + } + + private fun assertExpressionYieldsTree(expression: String, tree: AstNode?) { + val actual = parse(expression) + + if (tree != null) { + assertNotNull(actual) + } + + println(actual) + + assertEquals(tree, actual) + } + + private fun parse(expression: String): AstNode? { + val grammar = Grammar() + val lexer = Lexer(grammar) + val parser = Parser(grammar) + + return parser.parse(lexer.tokenize(expression)) + } +} diff --git a/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/value/JexlValueTest.kt b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/value/JexlValueTest.kt new file mode 100644 index 0000000000..8cef9cb0da --- /dev/null +++ b/mobile/android/android-components/components/lib/jexl/src/test/java/mozilla/components/lib/jexl/value/JexlValueTest.kt @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.jexl.value + +import mozilla.components.lib.jexl.Jexl +import mozilla.components.lib.jexl.JexlException +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import kotlin.reflect.KClass + +class JexlValueTest { + @Test + fun `double arithmetic`() { + "2.0 + 1".evaluatesTo(3.0) + "2.0 + 4.0".evaluatesTo(6.0) + "2.0 + 'a'".evaluatesTo("2.0a") + "2.0 + true".evaluatesTo(3.0) + "2.0 + false".evaluatesTo(2.0) + "2.0 + {}".evaluationThrows() + + "2.0 * 2".evaluatesTo(4.0) + "3.0 * 3.0".evaluatesTo(9.0) + "2.0 * true".evaluatesTo(2.0) + "2.0 * false".evaluatesTo(0.0) + "2.0 * a".evaluationThrows() + + "4.0 / 2".evaluatesTo(2.0) + "6.0 / 3.0".evaluatesTo(2.0) + "2.0 / 'a'".evaluationThrows() + + "2.0 == 2.0".evaluatesTo(true) + "2.0 == 2".evaluatesTo(true) + "2.1 == 2.0".evaluatesTo(false) + + "2.0 > 1.0".evaluatesTo(true) + "2.0 < 4.0".evaluatesTo(true) + "1.0 > 2.0".evaluatesTo(false) + "5.0 < 2.0".evaluatesTo(false) + "1.0 < 1.0".evaluatesTo(false) + "1.0 > 1.0".evaluatesTo(false) + } + + @Test + fun `integer arithmetic`() { + "2 + 1".evaluatesTo(3) + "2 + 4.0".evaluatesTo(6.0) + "2 + 'a'".evaluatesTo("2a") + "2 + true".evaluatesTo(3) + "2 + false".evaluatesTo(2) + "2 + {}".evaluationThrows() + + "2 * 2".evaluatesTo(4) + "3 * 3.0".evaluatesTo(9.0) + "2 * true".evaluatesTo(2) + "2 * false".evaluatesTo(0) + "2 * a".evaluationThrows() + + "4 / 2".evaluatesTo(2) + "6 / 3.0".evaluatesTo(2.0) + "2 / 'a'".evaluationThrows() + + "2 == 2.0".evaluatesTo(true) + "2 == 2".evaluatesTo(true) + + "2 > 1".evaluatesTo(true) + "2 < 4".evaluatesTo(true) + "1 > 2".evaluatesTo(false) + "5 < 2".evaluatesTo(false) + "1 < 1".evaluatesTo(false) + "1 > 1".evaluatesTo(false) + } + + @Test + fun `boolean arithmetic`() { + "true / false".evaluationThrows() + + "true * 2".evaluatesTo(2) + "false * 5".evaluatesTo(0) + "true * 2.0".evaluatesTo(2.0) + "false * 5.0".evaluatesTo(0.0) + "true * {}".evaluationThrows() + "true * []".evaluationThrows() + "true * true".evaluatesTo(1) + "false * false".evaluatesTo(0) + "true * false".evaluatesTo(0) + + "true + 1".evaluatesTo(2) + "false + 1".evaluatesTo(1) + "true + 1.0".evaluatesTo(2.0) + "false + 1.0".evaluatesTo(1.0) + "true + 'hello'".evaluatesTo("truehello") + "true + true".evaluatesTo(2) + "true + false".evaluatesTo(1) + "false + true".evaluatesTo(1) + "false + false".evaluatesTo(0) + "false + {}".evaluationThrows() + "true + []".evaluationThrows() + + "true > false".evaluationThrows() + "false < false".evaluationThrows() + + "true == true".evaluatesTo(true) + "false == false".evaluatesTo(true) + "true == false".evaluatesTo(false) + "false == true".evaluatesTo(false) + "true == 'hello'".evaluatesTo(false) + } + + @Test + fun `string arithmetic`() { + "'a' / 2".evaluationThrows() + "'a' * 2".evaluationThrows() + + "'hello' + 1".evaluatesTo("hello1") + "'hello' + 2.0".evaluatesTo("hello2.0") + "'hello' + ' ' + 'world'".evaluatesTo("hello world") + + "'hello' > 'world'".evaluationThrows() + "'world' < 'hello'".evaluationThrows() + + "'hello' == true".evaluatesTo(false) + "'' == true".evaluatesTo(false) + "'hello' == false".evaluatesTo(false) + "'' == false".evaluatesTo(false) + } + + @Test + fun `array arithmetic`() { + "[1,2,3] == [1,2,3]".evaluatesTo(true) + "[2,3,4] == [2,3,5]".evaluatesTo(false) + } + + @Test + fun `object arithmetic`() { + "{} / 2".evaluationThrows() + "{} * 5".evaluationThrows() + "{} + 'hello'".evaluationThrows() + "{} > 9".evaluationThrows() + + "{} == {}".evaluatesTo(true) + "{agent:'Archer'} == { agent: 'Archer' }".evaluatesTo(true) + "{a: 1, b: 2} == {b: 2, a: 1}".evaluatesTo(true) + "{} == 2".evaluatesTo(false) + } +} + +private fun String.evaluatesTo(expectedResult: Any, unpacked: Boolean = true) { + val jexl = Jexl() + val actualResult = jexl.evaluate(this) + + assertEquals(expectedResult, if (unpacked) actualResult.value else actualResult) +} + +private fun String.evaluationThrows() { + evaluationThrows(JexlException::class) +} + +private inline fun <reified T : Throwable> String.evaluationThrows(clazz: KClass<T>?) { + try { + evaluatesTo(Any()) + fail("Expected exception to be thrown: $clazz") + } catch (e: Throwable) { + if (e !is T) { + throw e + } + } +} diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/README.md b/mobile/android/android-components/components/lib/publicsuffixlist/README.md new file mode 100644 index 0000000000..1ac28bcb60 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/README.md @@ -0,0 +1,64 @@ +# [Android Components](../../../README.md) > Libraries > Public Suffix List + +A library for reading and using the Public Suffix List. + +> A "public suffix" is one under which Internet users can (or historically could) directly register names. Some examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known public suffixes. +> [https://publicsuffix.org/](https://publicsuffix.org/) + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-publicsuffixlist:{latest-version}" +``` + +### Using the public suffix list + +The `PublicSuffixList` class offers multiple methods for using the public suffix list data. For every instance the list needs to be read from disk into memory once. Therefore all methods return [Deferred](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-deferred/) types. The list data is cached in the `PublicSuffixList` and therefore it is recommended to keep a single instance in memory when frequently accessing the list. The list data can be prefetched to guarantee fast access for subsequent access. + +```Kotlin +val publicSuffixList = PublicSuffixList(context) + +// Not needed, but allows a consumer to decide when the read is happening: +publicSuffixList.prefetch() + // Optionally you can wait for the read to complete: +publicSuffixList.prefetch().await() +``` + +```Kotlin +// Extracting the effective top-level domain (eTLD) +publicSuffixList.getPublicSuffixPlusOne("www.mozilla.org") // -> mozilla.org +publicSuffixList.getPublicSuffixPlusOne("www.bbc.co.uk") // -> bbc.co.uk +publicSuffixList.getPublicSuffixPlusOne("a.b.ide.kyoto.jp") // -> b.ide.kyoto.jp +``` + +```Kotlin +// Checking whether a value is a public suffix: +publicSuffixList.isPublicSuffix("org") // -> true +publicSuffixList.isPublicSuffix("co.uk") // -> true +publicSuffixList.isPublicSuffix("org") // -> true +publicSuffixList.isPublicSuffix("ide.kyoto.jp") --> true +``` + +```Kotlin +// Extracting the public suffix from a domain +publicSuffixList.getPublicSuffix("www.mozilla.org") // -> org +publicSuffixList.getPublicSuffix("www.bbc.co.uk") // -> co.uk +publicSuffixList.getPublicSuffix("a.b.ide.kyoto.jp") // -> ide.kyoto.jp +``` + +```Kotlin +// Removing the public suffix from a domain +publicSuffixList.stripPublicSuffix("www.mozilla.org") // -> www.mozilla +publicSuffixList.stripPublicSuffix("foobar.blogspot.com") // -> foobar +publicSuffixList.stripPublicSuffix("www.example.pvt.k12.ma.us") // -> www.example +``` + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/build.gradle b/mobile/android/android-components/components/lib/publicsuffixlist/build.gradle new file mode 100644 index 0000000000..6728b4cc46 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/build.gradle @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +plugins { + id 'mozac.PublicSuffixListPlugin' +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'mozac.PublicSuffixListPlugin' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } + + namespace 'mozilla.components.lib.publicsuffixlist' +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_annotation + + testImplementation project(':support-test') + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/proguard-rules.pro b/mobile/android/android-components/components/lib/publicsuffixlist/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..41078a7325 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/main/assets/publicsuffixes b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/assets/publicsuffixes Binary files differnew file mode 100644 index 0000000000..6fbd7cfa64 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/assets/publicsuffixes diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt new file mode 100644 index 0000000000..dbaab5530d --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +/** + * API for reading and accessing the public suffix list. + * + * > A "public suffix" is one under which Internet users can (or historically could) directly register names. Some + * > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known + * > public suffixes. + * + * Note that this implementation applies the rules of the public suffix list only and does not validate domains. + * + * https://publicsuffix.org/ + * https://github.com/publicsuffix/list + */ +class PublicSuffixList( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val scope: CoroutineScope = CoroutineScope(dispatcher), +) { + private val data: PublicSuffixListData by lazy { PublicSuffixListLoader.load(context) } + + /** + * Prefetch the public suffix list from disk so that it is available in memory. + */ + fun prefetch(): Deferred<Unit> = scope.async { + data.run { Unit } + } + + /** + * Returns true if the given [domain] is a public suffix; false otherwise. + * + * E.g.: + * ``` + * co.uk -> true + * com -> true + * mozilla.org -> false + * org -> true + * ``` + * + * Note that this method ignores the default "prevailing rule" described in the formal public suffix list algorithm: + * If no rule matches then the passed [domain] is assumed to *not* be a public suffix. + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun isPublicSuffix(domain: String): Deferred<Boolean> = scope.async { + when (data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.PublicSuffix -> true + else -> false + } + } + + /** + * Returns the public suffix and one more level; known as the registrable domain. Returns `null` if + * [domain] is a public suffix itself. + * + * E.g.: + * ``` + * wwww.mozilla.org -> mozilla.org + * www.bcc.co.uk -> bbc.co.uk + * a.b.ide.kyoto.jp -> b.ide.kyoto.jp + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> + domain + .split('.') + .drop(offset.value) + .joinToString(separator = ".") + else -> null + } + } + + /** + * Returns the public suffix of the given [domain]; known as the effective top-level domain (eTLD). Returns `null` + * if the [domain] is a public suffix itself. + * + * E.g.: + * ``` + * wwww.mozilla.org -> org + * www.bcc.co.uk -> co.uk + * a.b.ide.kyoto.jp -> ide.kyoto.jp + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun getPublicSuffix(domain: String) = scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> + domain + .split('.') + .drop(offset.value + 1) + .joinToString(separator = ".") + else -> null + } + } + + /** + * Strips the public suffix from the given [domain]. Returns the original domain if no public suffix could be + * stripped. + * + * E.g.: + * ``` + * wwww.mozilla.org -> www.mozilla + * www.bcc.co.uk -> www.bbc + * a.b.ide.kyoto.jp -> a.b + * ``` + * + * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values + * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. + */ + fun stripPublicSuffix(domain: String) = scope.async { + when (val offset = data.getPublicSuffixOffset(domain)) { + is PublicSuffixOffset.Offset -> + domain + .split('.') + .joinToString(separator = ".", limit = offset.value + 1, truncated = "") + .dropLast(1) + else -> domain + } + } +} diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt new file mode 100644 index 0000000000..85986e5a4f --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import mozilla.components.lib.publicsuffixlist.ext.binarySearch +import java.net.IDN + +/** + * Class wrapping the public suffix list data and offering methods for accessing rules in it. + */ +internal class PublicSuffixListData( + private val rules: ByteArray, + private val exceptions: ByteArray, +) { + private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? { + return rules.binarySearch(labels, labelIndex) + } + + private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? { + return exceptions.binarySearch(labels, labelIndex) + } + + @Suppress("ReturnCount") + fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? { + if (domain.isEmpty()) { + return null + } + + val domainLabels = IDN.toUnicode(domain).split('.') + if (domainLabels.find { it.isEmpty() } != null) { + // At least one of the labels is empty: Bail out. + return null + } + + val rule = findMatchingRule(domainLabels) + + if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) { + // The domain is a public suffix. + return if (rule == PublicSuffixListData.PREVAILING_RULE) { + PublicSuffixOffset.PrevailingRule + } else { + PublicSuffixOffset.PublicSuffix + } + } + + return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) { + // Exception rules hold the effective TLD plus one. + PublicSuffixOffset.Offset(domainLabels.size - rule.size) + } else { + // Otherwise the rule is for a public suffix, so we must take one more label. + PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1)) + } + } + + /** + * Find a matching rule for the given domain labels. + * + * This algorithm is based on OkHttp's PublicSuffixDatabase class: + * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java + */ + private fun findMatchingRule(domainLabels: List<String>): List<String> { + // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com]. + val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) } + + val exactMatch = findExactMatch(domainLabelsBytes) + val wildcardMatch = findWildcardMatch(domainLabelsBytes) + val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch) + + if (exceptionMatch != null) { + return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.') + } + + if (exactMatch == null && wildcardMatch == null) { + return PublicSuffixListData.PREVAILING_RULE + } + + val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE + val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE + + return if (exactRuleLabels.size > wildcardRuleLabels.size) { + exactRuleLabels + } else { + wildcardRuleLabels + } + } + + /** + * Returns an exact match or null. + */ + private fun findExactMatch(labels: List<ByteArray>): String? { + // Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com + // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins. + + for (i in 0 until labels.size) { + val rule = binarySearchRules(labels, i) + + if (rule != null) { + return rule + } + } + + return null + } + + /** + * Returns a wildcard match or null. + */ + private fun findWildcardMatch(labels: List<ByteArray>): String? { + // In theory, wildcard rules are not restricted to having the wildcard in the leftmost position. + // In practice, wildcards are always in the leftmost position. For now, this implementation + // cheats and does not attempt every possible permutation. Instead, it only considers wildcards + // in the leftmost position. We assert this fact when we generate the public suffix file. If + // this assertion ever fails we'll need to refactor this implementation. + if (labels.size > 1) { + val labelsWithWildcard = labels.toMutableList() + for (labelIndex in 0 until labelsWithWildcard.size) { + labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL + val rule = binarySearchRules(labelsWithWildcard, labelIndex) + if (rule != null) { + return rule + } + } + } + + return null + } + + private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? { + // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard. + if (wildcardMatch == null) { + return null + } + + for (labelIndex in 0 until labels.size) { + val rule = binarySearchExceptions(labels, labelIndex) + if (rule != null) { + return rule + } + } + + return null + } + + companion object { + val WILDCARD_LABEL = byteArrayOf('*'.code.toByte()) + val PREVAILING_RULE = listOf("*") + val EMPTY_RULE = listOf<String>() + const val EXCEPTION_MARKER = '!' + } +} + +internal sealed class PublicSuffixOffset { + data class Offset(val value: Int) : PublicSuffixOffset() + object PublicSuffix : PublicSuffixOffset() + object PrevailingRule : PublicSuffixOffset() +} diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt new file mode 100644 index 0000000000..d9afcbe7b0 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import android.content.Context +import java.io.BufferedInputStream +import java.io.IOException + +private const val PUBLIC_SUFFIX_LIST_FILE = "publicsuffixes" + +internal object PublicSuffixListLoader { + fun load(context: Context): PublicSuffixListData = context.assets.open( + PUBLIC_SUFFIX_LIST_FILE, + ).buffered().use { stream -> + val publicSuffixSize = stream.readInt() + val publicSuffixBytes = stream.readFully(publicSuffixSize) + + val exceptionSize = stream.readInt() + val exceptionBytes = stream.readFully(exceptionSize) + + PublicSuffixListData(publicSuffixBytes, exceptionBytes) + } +} + +@Suppress("MagicNumber") +private fun BufferedInputStream.readInt(): Int { + return ( + read() and 0xff shl 24 + or (read() and 0xff shl 16) + or (read() and 0xff shl 8) + or (read() and 0xff) + ) +} + +private fun BufferedInputStream.readFully(size: Int): ByteArray { + val bytes = ByteArray(size) + + var offset = 0 + while (offset < size) { + val read = read(bytes, offset, size - offset) + if (read == -1) { + throw IOException("Unexpected end of stream") + } + offset += read + } + + return bytes +} diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt new file mode 100644 index 0000000000..c0a215ebe0 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist.ext + +import kotlin.experimental.and + +private const val BITMASK = 0xff.toByte() + +/** + * Performs a binary search for the provided [labels] on the [ByteArray]'s data. + * + * This algorithm is based on OkHttp's PublicSuffixDatabase class: + * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java + */ +@Suppress("ComplexMethod", "NestedBlockDepth") +internal fun ByteArray.binarySearch(labels: List<ByteArray>, labelIndex: Int): String? { + var low = 0 + var high = size + var match: String? = null + + while (low < high) { + val mid = (low + high) / 2 + val start = findStartOfLineFromIndex(mid) + val end = findEndOfLineFromIndex(start) + + val publicSuffixLength = start + end - start + + var compareResult: Int + var currentLabelIndex = labelIndex + var currentLabelByteIndex = 0 + var publicSuffixByteIndex = 0 + + var expectDot = false + while (true) { + val byte0 = if (expectDot) { + expectDot = false + '.'.code.toByte() + } else { + labels[currentLabelIndex][currentLabelByteIndex] and BITMASK + } + + val byte1 = this[start + publicSuffixByteIndex] and BITMASK + + // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the + // unsigned bytes. + @Suppress("EXPERIMENTAL_API_USAGE") + compareResult = (byte0.toUByte() - byte1.toUByte()).toInt() + if (compareResult != 0) { + break + } + + publicSuffixByteIndex++ + currentLabelByteIndex++ + + if (publicSuffixByteIndex == publicSuffixLength) { + break + } + + if (labels[currentLabelIndex].size == currentLabelByteIndex) { + // We've exhausted our current label. Either there are more labels to compare, in which + // case we expect a dot as the next character. Otherwise, we've checked all our labels. + if (currentLabelIndex == labels.size - 1) { + break + } else { + currentLabelIndex++ + currentLabelByteIndex = -1 + expectDot = true + } + } + } + + if (compareResult < 0) { + high = start - 1 + } else if (compareResult > 0) { + low = start + end + 1 + } else { + // We found a match, but are the lengths equal? + val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex + var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex + for (i in currentLabelIndex + 1 until labels.size) { + labelBytesLeft += labels[i].size + } + + if (labelBytesLeft < publicSuffixBytesLeft) { + high = start - 1 + } else if (labelBytesLeft > publicSuffixBytesLeft) { + low = start + end + 1 + } else { + // Found a match. + match = String(this, start, publicSuffixLength, Charsets.UTF_8) + break + } + } + } + + return match +} + +/** + * Search for a '\n' that marks the start of a value. Don't go back past the start of the array. + */ +private fun ByteArray.findStartOfLineFromIndex(start: Int): Int { + var index = start + while (index > -1 && this[index] != '\n'.code.toByte()) { + index-- + } + index++ + return index +} + +/** + * Search for a '\n' that marks the end of a value. + */ +private fun ByteArray.findEndOfLineFromIndex(start: Int): Int { + var end = 1 + while (this[start + end] != '\n'.code.toByte()) { + end++ + } + return end +} diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/test/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListTest.kt b/mobile/android/android-components/components/lib/publicsuffixlist/src/test/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListTest.kt new file mode 100644 index 0000000000..86ffd43ac9 --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/test/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListTest.kt @@ -0,0 +1,482 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.publicsuffixlist + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class PublicSuffixListTest { + + private val publicSuffixList + get() = PublicSuffixList(testContext) + + @Test + fun `Verify getPublicSuffixPlusOne for known domains`() = runTest { + assertEquals( + "mozilla.org", + publicSuffixList.getPublicSuffixPlusOne("www.mozilla.org").await(), + ) + + assertEquals( + "google.com", + publicSuffixList.getPublicSuffixPlusOne("google.com").await(), + ) + + assertEquals( + "foobar.blogspot.com", + publicSuffixList.getPublicSuffixPlusOne("foobar.blogspot.com").await(), + ) + + assertEquals( + "independent.co.uk", + publicSuffixList.getPublicSuffixPlusOne("independent.co.uk").await(), + ) + + assertEquals( + "independent.co.uk", + publicSuffixList.getPublicSuffixPlusOne("www.independent.co.uk").await(), + ) + + assertEquals( + "biz.com.ua", + publicSuffixList.getPublicSuffixPlusOne("www.biz.com.ua").await(), + ) + + assertEquals( + "example.org", + publicSuffixList.getPublicSuffixPlusOne("example.org").await(), + ) + + assertEquals( + "example.pvt.k12.ma.us", + publicSuffixList.getPublicSuffixPlusOne("www.example.pvt.k12.ma.us").await(), + ) + + assertEquals( + "δπθ.gr", + publicSuffixList.getPublicSuffixPlusOne("www.ουτοπία.δπθ.gr").await(), + ) + } + + @Test + fun `Verify getPublicSuffix for known domains`() = runTest { + assertEquals( + "org", + publicSuffixList.getPublicSuffix("www.mozilla.org").await(), + ) + + assertEquals( + "com", + publicSuffixList.getPublicSuffix("google.com").await(), + ) + + assertEquals( + "blogspot.com", + publicSuffixList.getPublicSuffix("foobar.blogspot.com").await(), + ) + + assertEquals( + "co.uk", + publicSuffixList.getPublicSuffix("independent.co.uk").await(), + ) + + assertEquals( + "co.uk", + publicSuffixList.getPublicSuffix("www.independent.co.uk").await(), + ) + + assertEquals( + "com.ua", + publicSuffixList.getPublicSuffix("www.biz.com.ua").await(), + ) + + assertEquals( + "org", + publicSuffixList.getPublicSuffix("example.org").await(), + ) + + assertEquals( + "pvt.k12.ma.us", + publicSuffixList.getPublicSuffix("www.example.pvt.k12.ma.us").await(), + ) + + assertEquals( + "gr", + publicSuffixList.getPublicSuffix("www.ουτοπία.δπθ.gr").await(), + ) + } + + @Test + fun `Verify stripPublicSuffix for known domains`() = runTest { + assertEquals( + "www.mozilla", + publicSuffixList.stripPublicSuffix("www.mozilla.org").await(), + ) + + assertEquals( + "google", + publicSuffixList.stripPublicSuffix("google.com").await(), + ) + + assertEquals( + "foobar", + publicSuffixList.stripPublicSuffix("foobar.blogspot.com").await(), + ) + + assertEquals( + "independent", + publicSuffixList.stripPublicSuffix("independent.co.uk").await(), + ) + + assertEquals( + "www.independent", + publicSuffixList.stripPublicSuffix("www.independent.co.uk").await(), + ) + + assertEquals( + "www.biz", + publicSuffixList.stripPublicSuffix("www.biz.com.ua").await(), + ) + + assertEquals( + "example", + publicSuffixList.stripPublicSuffix("example.org").await(), + ) + + assertEquals( + "www.example", + publicSuffixList.stripPublicSuffix("www.example.pvt.k12.ma.us").await(), + ) + + assertEquals( + "www.ουτοπία.δπθ", + publicSuffixList.stripPublicSuffix("www.ουτοπία.δπθ.gr").await(), + ) + } + + /** + * Short set of test data from: + * https://raw.githubusercontent.com/publicsuffix/list/master/tests/test_psl.txt + */ + @Test + fun `Verify getPublicSuffixPlusOne against official test data`() = runTest { + // empty input + assertNull(publicSuffixList.getPublicSuffixPlusOne("").await()) + + // Mixed case. + assertNull(publicSuffixList.getPublicSuffixPlusOne("COM").await()) + assertEquals( + "example.COM", + publicSuffixList.getPublicSuffixPlusOne("example.COM").await(), + ) + assertEquals( + "eXample.COM", + publicSuffixList.getPublicSuffixPlusOne("WwW.eXample.COM").await(), + ) + + // Leading dot. + // ArrayIndexOutOfBoundsException: assertEquals("", publicSuffixList.getPublicSuffixPlusOne(".example.com").await()) + + // TLD with only 1 rule. + assertNull(publicSuffixList.getPublicSuffixPlusOne("biz").await()) + assertEquals( + "domain.biz", + publicSuffixList.getPublicSuffixPlusOne("domain.biz").await(), + ) + assertEquals( + "domain.biz", + publicSuffixList.getPublicSuffixPlusOne("b.domain.biz").await(), + ) + assertEquals( + "domain.biz", + publicSuffixList.getPublicSuffixPlusOne("a.b.domain.biz").await(), + ) + + // TLD with some 2-level rules. + assertNull(publicSuffixList.getPublicSuffixPlusOne("com").await()) + assertEquals( + "example.com", + publicSuffixList.getPublicSuffixPlusOne("example.com").await(), + ) + assertEquals( + "example.com", + publicSuffixList.getPublicSuffixPlusOne("b.example.com").await(), + ) + assertEquals( + "example.com", + publicSuffixList.getPublicSuffixPlusOne("a.b.example.com").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("uk.com").await()) + assertEquals( + "example.uk.com", + publicSuffixList.getPublicSuffixPlusOne("example.uk.com").await(), + ) + assertEquals( + "example.uk.com", + publicSuffixList.getPublicSuffixPlusOne("b.example.uk.com").await(), + ) + assertEquals( + "example.uk.com", + publicSuffixList.getPublicSuffixPlusOne("a.b.example.uk.com").await(), + ) + assertEquals( + "test.ac", + publicSuffixList.getPublicSuffixPlusOne("test.ac").await(), + ) + + // TLD with only 1 (wildcard) rule. + assertNull(publicSuffixList.getPublicSuffixPlusOne("mm").await()) + assertNull(publicSuffixList.getPublicSuffixPlusOne("c.mm").await()) + assertEquals( + "b.c.mm", + publicSuffixList.getPublicSuffixPlusOne("b.c.mm").await(), + ) + assertEquals( + "b.c.mm", + publicSuffixList.getPublicSuffixPlusOne("a.b.c.mm").await(), + ) + + // More complex TLD. + assertNull(publicSuffixList.getPublicSuffixPlusOne("jp").await()) + assertNull(publicSuffixList.getPublicSuffixPlusOne("ac.jp").await()) + assertNull(publicSuffixList.getPublicSuffixPlusOne("kyoto.jp").await()) + assertNull(publicSuffixList.getPublicSuffixPlusOne("ide.kyoto.jp").await()) + assertNull(publicSuffixList.getPublicSuffixPlusOne("c.kobe.jp").await()) + assertEquals( + "test.jp", + publicSuffixList.getPublicSuffixPlusOne("test.jp").await(), + ) + assertEquals( + "test.jp", + publicSuffixList.getPublicSuffixPlusOne("www.test.jp").await(), + ) + assertEquals( + "test.ac.jp", + publicSuffixList.getPublicSuffixPlusOne("test.ac.jp").await(), + ) + assertEquals( + "test.ac.jp", + publicSuffixList.getPublicSuffixPlusOne("www.test.ac.jp").await(), + ) + assertEquals( + "test.kyoto.jp", + publicSuffixList.getPublicSuffixPlusOne("test.kyoto.jp").await(), + ) + assertEquals( + "b.ide.kyoto.jp", + publicSuffixList.getPublicSuffixPlusOne("b.ide.kyoto.jp").await(), + ) + assertEquals( + "b.ide.kyoto.jp", + publicSuffixList.getPublicSuffixPlusOne("a.b.ide.kyoto.jp").await(), + ) + assertEquals( + "b.c.kobe.jp", + publicSuffixList.getPublicSuffixPlusOne("b.c.kobe.jp").await(), + ) + assertEquals( + "b.c.kobe.jp", + publicSuffixList.getPublicSuffixPlusOne("a.b.c.kobe.jp").await(), + ) + assertEquals( + "city.kobe.jp", + publicSuffixList.getPublicSuffixPlusOne("city.kobe.jp").await(), + ) + assertEquals( + "city.kobe.jp", + publicSuffixList.getPublicSuffixPlusOne("www.city.kobe.jp").await(), + ) + + // TLD with a wildcard rule and exceptions. + assertNull(publicSuffixList.getPublicSuffixPlusOne("ck").await()) + assertNull(publicSuffixList.getPublicSuffixPlusOne("test.ck").await()) + assertEquals( + "b.test.ck", + publicSuffixList.getPublicSuffixPlusOne("b.test.ck").await(), + ) + assertEquals( + "b.test.ck", + publicSuffixList.getPublicSuffixPlusOne("a.b.test.ck").await(), + ) + assertEquals( + "www.ck", + publicSuffixList.getPublicSuffixPlusOne("www.ck").await(), + ) + assertEquals( + "www.ck", + publicSuffixList.getPublicSuffixPlusOne("www.www.ck").await(), + ) + + // US K12. + assertNull(publicSuffixList.getPublicSuffixPlusOne("us").await()) + assertEquals( + "test.us", + publicSuffixList.getPublicSuffixPlusOne("test.us").await(), + ) + assertEquals( + "test.us", + publicSuffixList.getPublicSuffixPlusOne("www.test.us").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("ak.us").await()) + assertEquals( + "test.ak.us", + publicSuffixList.getPublicSuffixPlusOne("www.test.ak.us").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("k12.ak.us").await()) + assertEquals( + "test.k12.ak.us", + publicSuffixList.getPublicSuffixPlusOne("test.k12.ak.us").await(), + ) + assertEquals( + "test.k12.ak.us", + publicSuffixList.getPublicSuffixPlusOne("www.test.k12.ak.us").await(), + ) + + // IDN labels. + assertEquals( + "食狮.com.cn", + publicSuffixList.getPublicSuffixPlusOne("食狮.com.cn").await(), + ) + // https://github.com/mozilla-mobile/android-components/issues/1777 + assertEquals( + "食狮.公司.cn", + publicSuffixList.getPublicSuffixPlusOne("食狮.公司.cn").await(), + ) + assertEquals( + "食狮.公司.cn", + publicSuffixList.getPublicSuffixPlusOne("www.食狮.公司.cn").await(), + ) + assertEquals( + "shishi.公司.cn", + publicSuffixList.getPublicSuffixPlusOne("shishi.公司.cn").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("公司.cn").await()) + assertEquals( + "食狮.中国", + publicSuffixList.getPublicSuffixPlusOne("食狮.中国").await(), + ) + assertEquals( + "食狮.中国", + publicSuffixList.getPublicSuffixPlusOne("www.食狮.中国").await(), + ) + assertEquals( + "shishi.中国", + publicSuffixList.getPublicSuffixPlusOne("shishi.中国").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("中国").await()) + + // Same as above, but punycoded. + assertEquals( + "xn--85x722f.com.cn", + publicSuffixList.getPublicSuffixPlusOne("xn--85x722f.com.cn").await(), + ) + // https://github.com/mozilla-mobile/android-components/issues/1777 + assertEquals( + "xn--85x722f.xn--55qx5d.cn", + publicSuffixList.getPublicSuffixPlusOne("xn--85x722f.xn--55qx5d.cn").await(), + ) + assertEquals( + "xn--85x722f.xn--55qx5d.cn", + publicSuffixList.getPublicSuffixPlusOne("www.xn--85x722f.xn--55qx5d.cn").await(), + ) + assertEquals( + "shishi.xn--55qx5d.cn", + publicSuffixList.getPublicSuffixPlusOne("shishi.xn--55qx5d.cn").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("xn--55qx5d.cn").await()) + assertEquals( + "xn--85x722f.xn--fiqs8s", + publicSuffixList.getPublicSuffixPlusOne("xn--85x722f.xn--fiqs8s").await(), + ) + assertEquals( + "xn--85x722f.xn--fiqs8s", + publicSuffixList.getPublicSuffixPlusOne("www.xn--85x722f.xn--fiqs8s").await(), + ) + assertEquals( + "shishi.xn--fiqs8s", + publicSuffixList.getPublicSuffixPlusOne("shishi.xn--fiqs8s").await(), + ) + assertNull(publicSuffixList.getPublicSuffixPlusOne("xn--fiqs8s").await()) + } + + @Test + fun `Accessing with and without prefetch`() = runTest { + run { + val publicSuffixList = PublicSuffixList(testContext) + assertEquals("org", publicSuffixList.getPublicSuffix("mozilla.org").await()) + } + + run { + val publicSuffixList = PublicSuffixList(testContext).apply { + prefetch().await() + } + assertEquals("org", publicSuffixList.getPublicSuffix("mozilla.org").await()) + } + } + + @Test + fun `Verify isPublicSuffix with known and unknown suffixes`() = runTest { + assertTrue(publicSuffixList.isPublicSuffix("org").await()) + assertTrue(publicSuffixList.isPublicSuffix("com").await()) + assertTrue(publicSuffixList.isPublicSuffix("us").await()) + assertTrue(publicSuffixList.isPublicSuffix("de").await()) + assertTrue(publicSuffixList.isPublicSuffix("de.com").await()) + assertTrue(publicSuffixList.isPublicSuffix("co.uk").await()) + assertTrue(publicSuffixList.isPublicSuffix("taxi.br").await()) + assertTrue(publicSuffixList.isPublicSuffix("edu.cw").await()) + assertTrue(publicSuffixList.isPublicSuffix("chirurgiens-dentistes.fr").await()) + assertTrue(publicSuffixList.isPublicSuffix("trani-andria-barletta.it").await()) + assertTrue(publicSuffixList.isPublicSuffix("yabuki.fukushima.jp").await()) + assertTrue(publicSuffixList.isPublicSuffix("research.museum").await()) + assertTrue(publicSuffixList.isPublicSuffix("lamborghini").await()) + assertTrue(publicSuffixList.isPublicSuffix("reisen").await()) + assertTrue(publicSuffixList.isPublicSuffix("github.io").await()) + + assertFalse(publicSuffixList.isPublicSuffix("").await()) + assertFalse(publicSuffixList.isPublicSuffix("mozilla").await()) + assertFalse(publicSuffixList.isPublicSuffix("mozilla.org").await()) + assertFalse(publicSuffixList.isPublicSuffix("ork").await()) + assertFalse(publicSuffixList.isPublicSuffix("us.com.uk").await()) + } + + /** + * Test cases inspired by Guava tests: + * https://github.com/google/guava/blob/master/guava-tests/test/com/google/common/net/InternetDomainNameTest.java + */ + @Test + fun `Verify getPublicSuffix can handle obscure and invalid input`() = runTest { + assertEquals("cOM", publicSuffixList.getPublicSuffix("f-_-o.cOM").await()) + assertEquals("com", publicSuffixList.getPublicSuffix("f11-1.com").await()) + assertNull(publicSuffixList.getPublicSuffix("www").await()) + assertEquals("a23", publicSuffixList.getPublicSuffix("abc.a23").await()) + assertEquals("com", publicSuffixList.getPublicSuffix("a\u0394b.com").await()) + assertNull(publicSuffixList.getPublicSuffix("").await()) + assertNull(publicSuffixList.getPublicSuffix(" ").await()) + assertNull(publicSuffixList.getPublicSuffix(".").await()) + assertNull(publicSuffixList.getPublicSuffix("..").await()) + assertNull(publicSuffixList.getPublicSuffix("...").await()) + assertNull(publicSuffixList.getPublicSuffix("woo.com.").await()) + assertNull(publicSuffixList.getPublicSuffix("::1").await()) + assertNull(publicSuffixList.getPublicSuffix("13").await()) + + // The following input returns an empty string which does not seem correct: + // https://github.com/mozilla-mobile/android-components/issues/3541 + assertEquals("", publicSuffixList.getPublicSuffix("foo.net.us\uFF61ocm").await()) + + // Technically that may be correct; but it doesn't make sense to return part of an IP as public suffix: + // https://github.com/mozilla-mobile/android-components/issues/3540 + assertEquals("1", publicSuffixList.getPublicSuffix("127.0.0.1").await()) + } +} diff --git a/mobile/android/android-components/components/lib/publicsuffixlist/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/publicsuffixlist/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/publicsuffixlist/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/push-firebase/README.md b/mobile/android/android-components/components/lib/push-firebase/README.md new file mode 100644 index 0000000000..f4e00ce8cc --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/README.md @@ -0,0 +1,59 @@ +# [Android Components](../../../README.md) > Libraries > Push-Firebase + +A [concept-push](../../concept/push/README.md) implementation using [Firebase Cloud Messaging](https://firebase.google.com/products/cloud-messaging/) (FCM). + +This implementation of `concept-push` uses [Firebase Cloud Messaging](https://firebase.google.com/products/cloud-messaging/). It can be used by Android devices that are supposed by Google Play Services. + +## Usage + +Add the push service for providing the encrypted messages: + +```kotlin +class FirebasePush : AbstractFirebasePushService() +``` + +Expose the service in the `AndroidManifest.xml`: +```xml +<service android:name=".push.FirebasePush"> + <intent-filter> + <action android:name="com.google.firebase.MESSAGING_EVENT" /> + </intent-filter> +</service> +``` + +The service can be started/stopped directly if required: +```kotlin +val service = FirebasePush() + +serivce.start() +serivce.stop() +``` + +See `feature-push` for more details on how to use the service with Autopush. + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-push-firebase:{latest-version}" +``` + +### Adding Firebase Support + +Extend `AbstractFirebasePushService` with your own class: +```kotlin +class FirebasePush : AbstractFirebasePushService() +``` + +Place your keys file (`google-services.json`) for FCM in the app module of the project. + +Optionally, add meta tags to your `AndroidManifest.xml` to disable the push service from automatically starting. + +See the [concept-push documentation](../../concept/push/README.md) for generic examples of using the API of components implementing `concept-push`. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/push-firebase/build.gradle b/mobile/android/android-components/components/lib/push-firebase/build.gradle new file mode 100644 index 0000000000..c8bc41debe --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/build.gradle @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.lib.push.firebase' +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + + implementation project(':concept-push') + implementation project(':support-base') + + api ComponentsDependencies.firebase_messaging + + testImplementation project(':support-test') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.testing_junit + testImplementation ComponentsDependencies.testing_robolectric +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/push-firebase/proguard-rules.pro b/mobile/android/android-components/components/lib/push-firebase/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/push-firebase/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/push-firebase/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/lib/push-firebase/src/main/java/mozilla/components/lib/push/firebase/AbstractFirebasePushService.kt b/mobile/android/android-components/components/lib/push-firebase/src/main/java/mozilla/components/lib/push/firebase/AbstractFirebasePushService.kt new file mode 100644 index 0000000000..e378f199a3 --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/src/main/java/mozilla/components/lib/push/firebase/AbstractFirebasePushService.kt @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.push.firebase + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.util.VisibleForTesting +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.push.PushError +import mozilla.components.concept.push.PushProcessor +import mozilla.components.concept.push.PushService +import mozilla.components.concept.push.PushService.Companion.MESSAGE_KEY_CHANNEL_ID +import mozilla.components.support.base.log.logger.Logger +import java.io.IOException +import kotlin.coroutines.CoroutineContext + +/** + * A Firebase Cloud Messaging implementation of the [PushService] for Android devices that support Google Play Services. + */ +abstract class AbstractFirebasePushService( + internal val coroutineContext: CoroutineContext = Dispatchers.IO, +) : FirebaseMessagingService(), PushService { + + private val logger = Logger("AbstractFirebasePushService") + + @VisibleForTesting + internal val googleApiAvailability: GoogleApiAvailability + get() = GoogleApiAvailability.getInstance() + + /** + * Initializes Firebase and starts the messaging service if not already started and enables auto-start as well. + */ + override fun start(context: Context) { + logger.info("start") + FirebaseApp.initializeApp(context) + } + + override fun onNewToken(newToken: String) { + logger.info("Got new Firebase token: $newToken") + PushProcessor.requireInstance.onNewToken(newToken) + } + + @SuppressWarnings("TooGenericExceptionCaught") + override fun onMessageReceived(message: RemoteMessage) { + logger.info("onMessageReceived") + // This is not an AutoPush message we can handle. + val chId = message.data.getOrElse(MESSAGE_KEY_CHANNEL_ID) { null } + + if (chId == null) { + logger.info("Missing $MESSAGE_KEY_CHANNEL_ID key, skipping this message") + return + } else { + logger.info("Processing message with chId: $chId") + } + + // In case of any errors, let the PushProcessor handle this exception. Instead of crashing + // here, just drop the message on the floor. This is fine, since we don't really need to + // "recover" from a bad incoming message. + // PushProcessor will submit relevant issues via a CrashReporter as appropriate. + try { + PushProcessor.requireInstance.onMessageReceived(message.data) + } catch (e: IllegalStateException) { + // Re-throw 'requireInstance' exceptions. + throw (e) + } catch (e: Exception) { + PushProcessor.requireInstance.onError(PushError.Rust(e)) + } + } + + /** + * Stops the Firebase messaging service and disables auto-start. + */ + final override fun stop() { + stopSelf() + } + + /** + * Removes the Firebase instance ID. This would lead a new token being generated when the + * service hits the Firebase servers. + */ + override fun deleteToken() { + CoroutineScope(coroutineContext).launch { + try { + FirebaseMessaging.getInstance().deleteToken() + } catch (e: IOException) { + logger.error("Force registration renewable failed.", e) + } + } + } + + override fun isServiceAvailable(context: Context): Boolean { + return googleApiAvailability.isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + } +} diff --git a/mobile/android/android-components/components/lib/push-firebase/src/test/java/mozilla/components/lib/push/firebase/AbstractFirebasePushServiceTest.kt b/mobile/android/android-components/components/lib/push-firebase/src/test/java/mozilla/components/lib/push/firebase/AbstractFirebasePushServiceTest.kt new file mode 100644 index 0000000000..b63cbbd6c4 --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/src/test/java/mozilla/components/lib/push/firebase/AbstractFirebasePushServiceTest.kt @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.push.firebase + +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.Dispatchers +import mozilla.components.concept.push.PushProcessor +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AbstractFirebasePushServiceTest { + + private val processor: PushProcessor = mock() + private val service = TestService() + + @Before + fun setup() { + reset(processor) + PushProcessor.install(processor) + } + + @Test + fun `onNewToken passes token to processor`() { + service.onNewToken("token") + + verify(processor).onNewToken("token") + } + + @Test + fun `new encrypted messages are passed to the processor`() { + val remoteMessage: RemoteMessage = mock() + val data = mapOf( + "chid" to "1234", + "body" to "contents", + "con" to "encoding", + "enc" to "salt", + "cryptokey" to "dh256", + ) + `when`(remoteMessage.data).thenReturn(data) + service.onMessageReceived(remoteMessage) + + verify(processor).onMessageReceived(data) + } + + @Test + fun `malformed message exception should not be thrown`() { + val remoteMessage: RemoteMessage = mock() + val data = mapOf( + "chid" to "1234", + ) + `when`(remoteMessage.data).thenReturn(data) + service.onMessageReceived(remoteMessage) + + verify(processor, never()).onError(any()) + verify(processor).onMessageReceived(data) + } + + @Test + fun `do nothing if the message is not for us`() { + val remoteMessage: RemoteMessage = mock() + val data = mapOf( + "con" to "encoding", + "enc" to "salt", + "cryptokey" to "dh256", + ) + `when`(remoteMessage.data).thenReturn(data) + + service.onMessageReceived(remoteMessage) + + verifyNoInteractions(processor) + } + + @Test + fun `force registration should never be on Main`() { + // Default dispatcher isn't main + assertTrue(service.coroutineContext != Dispatchers.Main) + + val service = object : AbstractFirebasePushService(Dispatchers.Default) {} + service.deleteToken() + } + + @Test + fun `service available reflects Google Play Services' availability`() { + val service = spy(TestService()) + + // By default, service is unavailable. + assertFalse(service.isServiceAvailable(testContext)) + + val googleApiAvailability = mock<GoogleApiAvailability>() + `when`(service.googleApiAvailability).thenReturn(googleApiAvailability) + `when`(googleApiAvailability.isGooglePlayServicesAvailable(testContext)).thenReturn(ConnectionResult.SUCCESS) + + assertTrue(service.isServiceAvailable(testContext)) + } + + class TestService : AbstractFirebasePushService() +} diff --git a/mobile/android/android-components/components/lib/push-firebase/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/lib/push-firebase/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/lib/push-firebase/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/push-firebase/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/push-firebase/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/lib/state/README.md b/mobile/android/android-components/components/lib/state/README.md new file mode 100644 index 0000000000..eb8f54712a --- /dev/null +++ b/mobile/android/android-components/components/lib/state/README.md @@ -0,0 +1,69 @@ +# [Android Components](../../../README.md) > Libraries > State + +A generic library for maintaining the state of a component, screen or application. + +The state library is inspired by existing libraries like [Redux](https://redux.js.org/) and provides a `Store` class to hold application state. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-state:{latest-version}" +``` + +### Action + +`Action`s represent payloads of information that send data from your application to the `Store`. You can send actions using `store.dispatch()`. An `Action` will usually be a small data class or object describing a change. + +```Kotlin +data class SetVisibility(val visible: Boolean) : Action + +store.dispatch(SetVisibility(true)) +``` + +### Reducer + +`Reducer`s are functions describing how the state should change in response to actions sent to the store. + +They take the previous state and an action as parameters, and return the new state as a result of that action. + +```Kotlin +fun reduce(previousState: State, action: Action) = when (action) { + is SetVisibility -> previousState.copy(toolbarVisible = action.visible) + else -> previousState +} +``` + +### Store + +The `Store` brings together actions and reducers. It holds the application state and allows access to it via the `store.state` getter. It allows state to be updated via `store.dispatch()`, and can have listeners registered through `store.observe()`. + +Stores can easily be created if you have a reducer. + +```Kotlin +val store = Store<State, Action>( + initialState = State(), + reducer = ::reduce +) +``` + +Once the store is created, you can react to changes in the state by registering an observer. + +```Kotlin +store.observe(lifecycleOwner) { state -> + toolbarView.visibility = if (state.toolbarVisible) View.VISIBLE else View.GONE +} +``` + +`store.observe` is lifecycle aware and will automatically unregister when the lifecycle owner (such as an `Activity` or `Fragment`) is destroyed. Instead of a `LifecycleOwner`, a `View` can be supplied instead. + +If you wish to manually control the observer subscription, you can use the `store.observeManually` function. `observeManually` returns a `Subscription` class which has an `unsubscribe` method. Calling `unsubscribe` removes the observer. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/state/build.gradle b/mobile/android/android-components/components/lib/state/build.gradle new file mode 100644 index 0000000000..4424fce465 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/build.gradle @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + namespace 'mozilla.components.lib.state' +} + +tasks.withType(KotlinCompile).configureEach { + kotlinOptions.freeCompilerArgs += [ + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" + ] +} + +dependencies { + implementation platform(ComponentsDependencies.androidx_compose_bom) + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_fragment + implementation ComponentsDependencies.androidx_compose_ui + implementation ComponentsDependencies.androidx_lifecycle_process + + implementation project(':support-base') + implementation project(':support-ktx') + + testImplementation platform(ComponentsDependencies.androidx_compose_bom) + testImplementation project(':support-test') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.androidx_compose_ui_test + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + + androidTestImplementation ComponentsDependencies.androidx_test_junit + androidTestImplementation ComponentsDependencies.androidx_compose_ui_test_manifest + androidTestImplementation ComponentsDependencies.androidx_compose_ui_test +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/state/proguard-rules.pro b/mobile/android/android-components/components/lib/state/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/state/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt b/mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt new file mode 100644 index 0000000000..a97ceebe0d --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import androidx.compose.ui.test.junit4.createComposeRule +import kotlinx.coroutines.runBlocking +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ComposeExtensionsKtTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun usingInitialValue() { + val store = Store( + initialState = TestState(counter = 42), + reducer = ::reducer, + ) + + var value: Int? = null + + rule.setContent { + val composeState = store.observeAsComposableState { state -> state.counter * 2 } + value = composeState.value + } + + assertEquals(84, value) + } + + @Test + fun receivingUpdates() { + val store = Store( + initialState = TestState(counter = 42), + reducer = ::reducer, + ) + + var value: Int? = null + + rule.setContent { + val composeState = store.observeAsComposableState { state -> state.counter * 2 } + value = composeState.value + } + + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + assertEquals(86, value) + } + } + + @Test + fun usingInitialValueWithUpdates() { + val loading = "Loading" + val content = "Content" + val store = Store( + initialState = TestState(counter = 0), + reducer = ::reducer, + ) + + val value = mutableListOf<String>() + + rule.setContent { + val composeState = store.observeAsState( + initialValue = loading, + map = { if (it.counter < 5) loading else content }, + ) + value.add(composeState.value) + } + + rule.runOnIdle { + // Initial value when counter is 0. + assertEquals(listOf("Loading"), value) + } + + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + // Value after 4 increments, aka counter is 4. Note that it doesn't recompose here + // as the mapped value has stayed the same. We have 1 item in the list and not 5. + assertEquals(listOf(loading), value) + } + + // 5th increment + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + assertEquals(listOf(loading, content), value) + assertEquals(content, value.last()) + } + } + + @Test + fun receivingUpdatesForPartialStateUpdateOnly() { + val store = Store( + initialState = TestState(counter = 42), + reducer = ::reducer, + ) + + var value: Int? = null + + rule.setContent { + val composeState = store.observeAsComposableState( + map = { state -> state.counter * 2 }, + observe = { state -> state.text }, + ) + value = composeState.value + } + + assertEquals(84, value) + + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + // State value didn't change because value returned by `observer` function did not change + assertEquals(84, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World")) + + rule.runOnIdle { + // Now, after the value from the observer function changed, we are seeing the new value + assertEquals(86, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetValueAction(23)) + + rule.runOnIdle { + // Observer function result is the same, no state update + assertEquals(86, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World")) + + rule.runOnIdle { + // Text was updated to the same value, observer function result is the same, no state update + assertEquals(86, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World Again")) + + rule.runOnIdle { + // Now, after the value from the observer function changed, we are seeing the new value + assertEquals(46, value) + } + } + + private fun Store<TestState, TestAction>.dispatchBlockingOnIdle(action: TestAction) { + rule.runOnIdle { + val job = dispatch(action) + runBlocking { job.join() } + } + } +} + +fun reducer(state: TestState, action: TestAction): TestState = when (action) { + is TestAction.IncrementAction -> state.copy(counter = state.counter + 1) + is TestAction.DecrementAction -> state.copy(counter = state.counter - 1) + is TestAction.SetValueAction -> state.copy(counter = action.value) + is TestAction.SetTextAction -> state.copy(text = action.text) +} + +data class TestState( + val counter: Int, + val text: String = "", +) : State + +sealed class TestAction : Action { + object IncrementAction : TestAction() + object DecrementAction : TestAction() + data class SetValueAction(val value: Int) : TestAction() + data class SetTextAction(val text: String) : TestAction() +} diff --git a/mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..aa9d1077cc --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest> + + <application /> + +</manifest> diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt new file mode 100644 index 0000000000..e371ac8929 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Generic interface for actions to be dispatched on a [Store]. + * + * Actions are used to send data from the application to a [Store]. The [Store] will use the [Action] to + * derive a new [State]. Actions should describe what happened, while [Reducer]s will describe how the + * state changes. + */ +interface Action diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt new file mode 100644 index 0000000000..18f48b8483 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * + * Marks an [Action] in the [Store] that are **delicate** — + * they have limited use-case and shall ve used with care in general code. + * Any use of a delicate declaration has to be carefully reviewed to make sure it is + * properly used and is not used for non-debugging or testing purposes. + * Carefully read documentation of any declaration marked as `DelicateAction`. + */ +@MustBeDocumented +@Retention(value = AnnotationRetention.BINARY) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "This is a delicate Action and should only be used for situations that require debugging or testing." + + " Make sure you fully read and understand documentation of the action that is marked as a delicate Action.", +) +@Target(AnnotationTarget.CLASS) +public annotation class DelicateAction diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt new file mode 100644 index 0000000000..777b8cb77b --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * A [Middleware] sits between the store and the reducer. It provides an extension point between + * dispatching an action, and the moment it reaches the reducer. + * + * A [Middleware] can rewrite an [Action], it can intercept an [Action], dispatch additional + * [Action]s or perform side-effects when an [Action] gets dispatched. + * + * The [Store] will create a chain of [Middleware] instances and invoke them in order. Every + * [Middleware] can decide to continue the chain (by calling `next`), intercept the chain (by not + * invoking `next`). A [Middleware] has no knowledge of what comes before or after it in the chain. + */ +typealias Middleware<S, A> = (context: MiddlewareContext<S, A>, next: (A) -> Unit, action: A) -> Unit + +/** + * The context a Middleware is running in. Allows access to privileged [Store] functionality. It is + * passed to a [Middleware] with every [Action]. + * + * Note that the [MiddlewareContext] should not be passed to other components and calling methods + * on non-[Store] threads may throw an exception. Instead the value of the [store] property, granting + * access to the underlying store, can safely be used outside of the middleware. + */ +interface MiddlewareContext<S : State, A : Action> { + /** + * Returns the current state of the [Store]. + */ + val state: S + + /** + * Dispatches an [Action] synchronously on the [Store]. Other than calling [Store.dispatch], this + * will block and return after all [Store] observers have been notified about the state change. + * The dispatched [Action] will go through the whole chain of middleware again. + * + * This method is particular useful if a middleware wants to dispatch an additional [Action] and + * wait until the [state] has been updated to further process it. + * + * Note that this method should only ever be called from a [Middleware] and the calling thread. + * Calling it from another thread may throw an exception. For dispatching an [Action] from + * asynchronous code in the [Middleware] or another component use [store] which returns a + * reference to the underlying [Store] that offers methods for asynchronous dispatching. + */ + fun dispatch(action: A) + + /** + * Returns a reference to the [Store] the [Middleware] is running in. + */ + val store: Store<S, A> +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt new file mode 100644 index 0000000000..c5a68d8c17 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Listener called when the state changes in the [Store]. + */ +typealias Observer<S> = (S) -> Unit diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt new file mode 100644 index 0000000000..0cfcf76bb6 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Reducers specify how the application's [State] changes in response to [Action]s sent to the [Store]. + * + * Remember that actions only describe what happened, but don't describe how the application's state changes. + * Reducers will commonly consist of a `when` statement returning different copies of the [State]. + */ +typealias Reducer<S, A> = (S, A) -> S diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt new file mode 100644 index 0000000000..3318ddffe8 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Generic interface for a [State] maintained by a [Store]. + */ +interface State diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt new file mode 100644 index 0000000000..025880d780 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +import android.os.Handler +import android.os.Looper +import androidx.annotation.CheckResult +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import mozilla.components.lib.state.internal.ReducerChainBuilder +import mozilla.components.lib.state.internal.StoreThreadFactory +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * A generic store holding an immutable [State]. + * + * The [State] can only be modified by dispatching [Action]s which will create a new state and notify all registered + * [Observer]s. + * + * @param initialState The initial state until a dispatched [Action] creates a new state. + * @param reducer A function that gets the current [State] and [Action] passed in and will return a new [State]. + * @param middleware Optional list of [Middleware] sitting between the [Store] and the [Reducer]. + * @param threadNamePrefix Optional prefix with which to name threads for the [Store]. If not provided, + * the naming scheme will be deferred to [Executors.defaultThreadFactory] + */ +open class Store<S : State, A : Action>( + initialState: S, + reducer: Reducer<S, A>, + middleware: List<Middleware<S, A>> = emptyList(), + threadNamePrefix: String? = null, +) { + private val threadFactory = StoreThreadFactory(threadNamePrefix) + private val dispatcher = Executors.newSingleThreadExecutor(threadFactory).asCoroutineDispatcher() + private val reducerChainBuilder = ReducerChainBuilder(threadFactory, reducer, middleware) + private val scope = CoroutineScope(dispatcher) + + @VisibleForTesting + internal val subscriptions = Collections.newSetFromMap(ConcurrentHashMap<Subscription<S, A>, Boolean>()) + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + // We want exceptions in the reducer to crash the app and not get silently ignored. Therefore we rethrow the + // exception on the main thread. + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + throw StoreException("Exception while reducing state", throwable) + } + + // Once an exception happened we do not want to accept any further actions. So let's cancel the scope which + // will cancel all jobs and not accept any new ones. + scope.cancel() + } + private val dispatcherWithExceptionHandler = dispatcher + exceptionHandler + + @Volatile private var currentState = initialState + + /** + * The current [State]. + */ + val state: S + get() = currentState + + /** + * Registers an [Observer] function that will be invoked whenever the [State] changes. + * + * It's the responsibility of the caller to keep track of the returned [Subscription] and call + * [Subscription.unsubscribe] to stop observing and avoid potentially leaking memory by keeping an unused [Observer] + * registered. It's is recommend to use one of the `observe` extension methods that unsubscribe automatically. + * + * The created [Subscription] is in paused state until explicitly resumed by calling [Subscription.resume]. + * While paused the [Subscription] will not receive any state updates. Once resumed the [observer] + * will get invoked immediately with the latest state. + * + * @return A [Subscription] object that can be used to unsubscribe from further state changes. + */ + @CheckResult(suggest = "observe") + @Synchronized + fun observeManually(observer: Observer<S>): Subscription<S, A> { + val subscription = Subscription(observer, store = this) + subscriptions.add(subscription) + + return subscription + } + + /** + * Dispatch an [Action] to the store in order to trigger a [State] change. + */ + fun dispatch(action: A) = scope.launch(dispatcherWithExceptionHandler) { + synchronized(this@Store) { + reducerChainBuilder.get(this@Store).invoke(action) + } + } + + /** + * Transitions from the current [State] to the passed in [state] and notifies all observers. + */ + internal fun transitionTo(state: S) { + if (state == currentState) { + // Nothing has changed. + return + } + + currentState = state + subscriptions.forEach { subscription -> subscription.dispatch(state) } + } + + private fun removeSubscription(subscription: Subscription<S, A>) { + subscriptions.remove(subscription) + } + + /** + * A [Subscription] is returned whenever an observer is registered via the [observeManually] method. Calling + * [unsubscribe] on the [Subscription] will unregister the observer. + */ + class Subscription<S : State, A : Action> internal constructor( + internal val observer: Observer<S>, + store: Store<S, A>, + ) { + private val storeReference = WeakReference(store) + internal var binding: Binding? = null + private var active = false + + /** + * Resumes the [Subscription]. The [Observer] will get notified for every state change. + * Additionally it will get invoked immediately with the latest state. + */ + @Synchronized + fun resume() { + active = true + + storeReference.get()?.state?.let(observer) + } + + /** + * Pauses the [Subscription]. The [Observer] will not get notified when the state changes + * until [resume] is called. + */ + @Synchronized + fun pause() { + active = false + } + + /** + * Notifies this subscription's observer of a state change. + * + * @param state the updated state. + */ + @Synchronized + internal fun dispatch(state: S) { + if (active) { + observer.invoke(state) + } + } + + /** + * Unsubscribe from the [Store]. + * + * Calling this method will clear all references and the subscription will not longer be + * active. + */ + @Synchronized + fun unsubscribe() { + active = false + + storeReference.get()?.removeSubscription(this) + storeReference.clear() + + binding?.unbind() + } + + interface Binding { + fun unbind() + } + } +} + +/** + * Exception for otherwise unhandled errors caught while reducing state or + * while managing/notifying observers. + */ +class StoreException(msg: String, val e: Throwable? = null) : Exception(msg, e) diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt new file mode 100644 index 0000000000..e4633b6239 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt @@ -0,0 +1,147 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import androidx.compose.runtime.State as ComposeState + +/** + * Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState]. + * + * Every time the mapped [Store] state changes, the returned [ComposeState] will be updated causing + * recomposition of every [ComposeState.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + */ +@Composable +fun <S : State, A : Action, R> Store<S, A>.observeAsComposableState(map: (S) -> R): ComposeState<R?> { + val lifecycleOwner = LocalLifecycleOwner.current + val state = remember { mutableStateOf<R?>(map(state)) } + + DisposableEffect(this, lifecycleOwner) { + val subscription = observe(lifecycleOwner) { browserState -> + state.value = map(browserState) + } + onDispose { subscription?.unsubscribe() } + } + + return state +} + +/** + * Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState]. + * + * Every time the mapped [Store] state changes, the returned [ComposeState] will be updated causing + * recomposition of every [ComposeState.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + * + * @param initialValue Initial value emitted. + * @param map The applied function to produced the mapped value [R] from [S]. + * @return A non nullable [ComposeState], making the api more reasonable for callers where the + * state is non null. + */ +@Composable +fun <S : State, A : Action, R> Store<S, A>.observeAsState( + initialValue: R, + map: (S) -> R, +): ComposeState<R> { + val lifecycleOwner = LocalLifecycleOwner.current + + return produceState(initialValue = initialValue) { + val subscription = observe(lifecycleOwner) { browserState -> + value = map(browserState) + } + awaitDispose { subscription?.unsubscribe() } + } +} + +/** + * Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState]. + * + * Everytime the [Store] state changes and the result of the [observe] function changes for this + * state, the returned [ComposeState] will be updated causing recomposition of every + * [ComposeState.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + */ +@Composable +fun <S : State, A : Action, O, R> Store<S, A>.observeAsComposableState( + observe: (S) -> O, + map: (S) -> R, +): ComposeState<R?> { + val lifecycleOwner = LocalLifecycleOwner.current + var lastValue = observe(state) + val state = remember { mutableStateOf<R?>(map(state)) } + + DisposableEffect(this, lifecycleOwner) { + val subscription = observe(lifecycleOwner) { browserState -> + val newValue = observe(browserState) + if (newValue != lastValue) { + state.value = map(browserState) + lastValue = newValue + } + } + onDispose { subscription?.unsubscribe() } + } + + return state +} + +/** + * Helper for creating a [Store] scoped to a `@Composable` and whose [State] gets saved and restored + * on process recreation. + */ +@Composable +inline fun <reified S : State, A : Action> composableStore( + crossinline save: (S) -> Parcelable = { state -> + if (state is Parcelable) { + state + } else { + throw NotImplementedError( + "State of store does not implement Parcelable. Either implement Parcelable or pass " + + "custom save function to composableStore()", + ) + } + }, + crossinline restore: (Parcelable) -> S = { parcelable -> + if (parcelable is S) { + parcelable + } else { + throw NotImplementedError( + "Restored parcelable is not of same class as state. Either the state needs to " + + "implement Parcelable or you need to provide a custom restore function to composableStore()", + ) + } + }, + crossinline init: (S?) -> Store<S, A>, +): Store<S, A> { + return rememberSaveable( + saver = Saver( + save = { store -> save(store.state) }, + restore = { parcelable -> + val state = restore(parcelable) + init(state) + }, + ), + init = { init(null) }, + ) +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt new file mode 100644 index 0000000000..0eacb1de04 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.annotation.MainThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.support.ktx.android.view.toScope + +/** + * Helper extension method for consuming [State] from a [Store] sequentially in order inside a + * [Fragment]. The [block] function will get invoked for every [State] update. + * + * This helper will automatically stop observing the [Store] once the [View] of the [Fragment] gets + * detached. The fragment's lifecycle will be used to determine when to resume/pause observing the + * [Store]. + */ +@MainThread +fun <S : State, A : Action> Fragment.consumeFrom(store: Store<S, A>, block: (S) -> Unit) { + val fragment = this + val view = checkNotNull(view) { "Fragment has no view yet. Call from onViewCreated()." } + + val scope = view.toScope() + val channel = store.channel(owner = this) + + scope.launch { + channel.consumeEach { state -> + // We are using a scope that is bound to the view being attached here. It can happen + // that the "view detached" callback gets executed *after* the fragment was detached. If + // a `consumeFrom` runs in exactly this moment then we run inside a detached fragment + // without a `Context` and this can cause a variety of issues/crashes. + // See: https://github.com/mozilla-mobile/android-components/issues/4125 + // + // To avoid this, we check whether the fragment still has an activity and a view + // attached. If not then we run in exactly that moment between fragment detach and view + // detach. It would be better if we could use `viewLifecycleOwner` which is bound to + // onCreateView() and onDestroyView() of the fragment. But: + // - `viewLifecycleOwner` is only available in alpha versions of AndroidX currently. + // - We found a bug where `viewLifecycleOwner.lifecycleScope` is not getting cancelled + // causing this coroutine to run forever. + // See: https://github.com/mozilla-mobile/android-components/issues/3828 + // Once those two issues get resolved we can remove the `isAdded` check and use + // `viewLifecycleOwner.lifecycleScope` instead of the view scope. + // + // In a previous version we tried using `isAdded` and `isDetached` here. But in certain + // situations they reported true/false in situations where no activity was attached to + // the fragment. Therefore we switched to explicitly check for the activity and view here. + if (fragment.activity != null && fragment.view != null) { + block(state) + } + } + } +} + +/** + * Helper extension method for consuming [State] from a [Store] as a [Flow]. + * + * The lifetime of the coroutine scope the [Flow] is launched in, and [block] is executed in, is + * bound to the [View] of the [Fragment]. Once the [View] gets detached, the coroutine scope will + * automatically be cancelled and no longer observe the [Store]. + * + * An optional [LifecycleOwner] can be passed to this method. It will be used to automatically pause + * and resume the [Store] subscription. With that an application can, for example, automatically + * stop updating the UI if the application is in the background. Once the [Lifecycle] switches back + * to at least STARTED state then the latest [State] and further will be passed to the [Flow] again. + * By default, the fragment itself is used as a [LifecycleOwner]. + */ +@MainThread +fun <S : State, A : Action> Fragment.consumeFlow( + from: Store<S, A>, + owner: LifecycleOwner? = this, + block: suspend (Flow<S>) -> Unit, +) { + val fragment = this + val view = checkNotNull(view) { "Fragment has no view yet. Call from onViewCreated()." } + + // It's important to create the flow here directly instead of in the coroutine below, + // as otherwise the fragment could be removed before the subscription is created. + // This would cause us to create an unnecessary subscription leaking the fragment, + // as we only unsubscribe on destroy which already happened. + val flow = from.flow(owner) + + val scope = view.toScope() + scope.launch { + val filtered = flow.filter { + // We ignore state updates if the fragment does not have an activity or view + // attached anymore. + // See comment in [consumeFrom] above. + fragment.activity != null && fragment.view != null + } + + block(filtered) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt new file mode 100644 index 0000000000..8250bf1376 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.annotation.MainThread +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Observer +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Registers an [Observer] function that will be invoked whenever the state changes. The [Store.Subscription] + * will be bound to the passed in [LifecycleOwner]. Once the [Lifecycle] state changes to DESTROYED the [Observer] will + * be unregistered automatically. + * + * The [Observer] will get invoked with the current [State] as soon as the [Lifecycle] is in STARTED + * state. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.observe( + owner: LifecycleOwner, + observer: Observer<S>, +): Store.Subscription<S, A>? { + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) { + // This owner is already destroyed. No need to register. + return null + } + + val subscription = observeManually(observer) + + subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply { + owner.lifecycle.addObserver(this) + } + + return subscription +} + +/** + * Registers an [Observer] function that will be invoked whenever the state changes. The [Store.Subscription] + * will be bound to the passed in [View]. Once the [View] gets detached the [Observer] will be unregistered + * automatically. + * + * Note that inside a `Fragment` using [observe] with a `viewLifecycleOwner` may be a better option. + * Only use this implementation if you have only access to a [View] - especially if it can exist + * outside of a `Fragment`. + * + * The [Observer] will get invoked with the current [State] as soon as [View] is attached. + * + * Once the [View] gets detached the [Observer] will get unregistered. It will NOT get automatically + * registered again if the same [View] gets attached again. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.observe( + view: View, + observer: Observer<S>, +) { + val subscription = observeManually(observer) + + subscription.binding = SubscriptionViewBinding(view, subscription).apply { + view.addOnAttachStateChangeListener(this) + } + + if (view.isAttachedToWindow) { + // This View is already attached. We can resume immediately and do not need to wait for + // onViewAttachedToWindow() getting called. + subscription.resume() + } +} + +/** + * Registers an [Observer] function that will observe the store indefinitely. + * + * Right after registering the [Observer] will be invoked with the current [State]. + */ +fun <S : State, A : Action> Store<S, A>.observeForever( + observer: Observer<S>, +) { + observeManually(observer).resume() +} + +/** + * Creates a conflated [Channel] for observing [State] changes in the [Store]. + * + * The advantage of a [Channel] is that [State] changes can be processed sequentially in order from + * a single coroutine (e.g. on the main thread). + * + * @param owner A [LifecycleOwner] that will be used to determine when to pause and resume the store + * subscription. When the [Lifecycle] is in STOPPED state then no [State] will be received. Once the + * [Lifecycle] switches back to at least STARTED state then the latest [State] and further updates + * will be received. + */ +@ExperimentalCoroutinesApi +@MainThread +fun <S : State, A : Action> Store<S, A>.channel( + owner: LifecycleOwner = ProcessLifecycleOwner.get(), +): ReceiveChannel<S> { + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) { + // This owner is already destroyed. No need to register. + throw IllegalArgumentException("Lifecycle is already DESTROYED") + } + + val channel = Channel<S>(Channel.CONFLATED) + + val subscription = observeManually { state -> + runBlocking { + try { + channel.send(state) + } catch (e: CancellationException) { + // It's possible for this channel to have been closed concurrently before + // we had a chance to unsubscribe. In this case we can just ignore this + // one subscription and keep going. + } + } + } + + subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply { + owner.lifecycle.addObserver(this) + } + + channel.invokeOnClose { subscription.unsubscribe() } + + return channel +} + +/** + * Creates a [Flow] for observing [State] changes in the [Store]. + * + * @param owner An optional [LifecycleOwner] that will be used to determine when to pause and resume + * the store subscription. When the [Lifecycle] is in STOPPED state then no [State] will be received. + * Once the [Lifecycle] switches back to at least STARTED state then the latest [State] and further + * updates will be emitted. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.flow( + owner: LifecycleOwner? = null, +): Flow<S> { + var destroyed = owner?.lifecycle?.currentState == Lifecycle.State.DESTROYED + val ownerDestroyedObserver = object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + destroyed = true + } + } + owner?.lifecycle?.addObserver(ownerDestroyedObserver) + + return channelFlow { + // By the time this block executes the fragment or view could already be destroyed + // so we exit early to avoid creating an unnecessary subscription. This is important + // as otherwise we'd be leaking the owner via the subscription because we only + // unsubscribe on destroy which already happened. + if (destroyed) { + return@channelFlow + } + + owner?.lifecycle?.removeObserver(ownerDestroyedObserver) + + val subscription = observeManually { state -> + runBlocking { + try { + send(state) + } catch (e: CancellationException) { + // It's possible for this channel to have been closed concurrently before + // we had a chance to unsubscribe. In this case we can just ignore this + // one subscription and keep going. + } + } + } + + if (owner == null) { + subscription.resume() + } else { + subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply { + owner.lifecycle.addObserver(this) + } + } + + awaitClose { + subscription.unsubscribe() + } + }.buffer(Channel.CONFLATED) +} + +/** + * Launches a coroutine in a new [MainScope] and creates a [Flow] for observing [State] changes in + * the [Store] in that scope. Invokes [block] inside that scope and passes the [Flow] to it. + * + * @param owner An optional [LifecycleOwner] that will be used to determine when to pause and resume + * the store subscription. When the [Lifecycle] is in STOPPED state then no [State] will be received. + * Once the [Lifecycle] switches back to at least STARTED state then the latest [State] and further + * updates will be emitted. + * @return The [CoroutineScope] [block] is getting executed in. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.flowScoped( + owner: LifecycleOwner? = null, + block: suspend (Flow<S>) -> Unit, +): CoroutineScope { + return MainScope().apply { + launch { + block(flow(owner)) + } + } +} + +/** + * GenericLifecycleObserver implementation to bind an observer to a Lifecycle. + */ +private class SubscriptionLifecycleBinding<S : State, A : Action>( + private val owner: LifecycleOwner, + private val subscription: Store.Subscription<S, A>, +) : DefaultLifecycleObserver, Store.Subscription.Binding { + override fun onStart(owner: LifecycleOwner) { + subscription.resume() + } + + override fun onStop(owner: LifecycleOwner) { + subscription.pause() + } + + override fun onDestroy(owner: LifecycleOwner) { + subscription.unsubscribe() + } + + override fun unbind() { + owner.lifecycle.removeObserver(this) + } +} + +/** + * View.OnAttachStateChangeListener implementation to bind an observer to a View. + */ +private class SubscriptionViewBinding<S : State, A : Action>( + private val view: View, + private val subscription: Store.Subscription<S, A>, +) : View.OnAttachStateChangeListener, Store.Subscription.Binding { + override fun onViewAttachedToWindow(v: View) { + subscription.resume() + } + + override fun onViewDetachedFromWindow(view: View) { + subscription.unsubscribe() + } + + override fun unbind() { + view.removeOnAttachStateChangeListener(this) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt new file mode 100644 index 0000000000..aafe7d1ed9 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.support.ktx.android.view.toScope + +/** + * Helper extension method for consuming [State] from a [Store] sequentially in order scoped to the + * lifetime of the [View]. The [block] function will get invoked for every [State] update. + * + * This helper will automatically stop observing the [Store] once the [View] gets detached. The + * provided [LifecycleOwner] is used to determine when observing should be stopped or resumed. + * + * Inside a [Fragment] prefer to use [Fragment.consumeFrom]. + */ +@ExperimentalCoroutinesApi // Channel +fun <S : State, A : Action> View.consumeFrom( + store: Store<S, A>, + owner: LifecycleOwner, + block: (S) -> Unit, +) { + val scope = toScope() + val channel = store.channel(owner) + + scope.launch { + channel.consumeEach { state -> block(state) } + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt new file mode 100644 index 0000000000..10a0859192 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.helpers + +import androidx.annotation.CallSuper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature + +/** + * Helper class for creating small binding classes that are responsible for reacting to state + * changes. + */ +@ExperimentalCoroutinesApi // Flow +abstract class AbstractBinding<in S : State>( + private val store: Store<S, out Action>, +) : LifecycleAwareFeature { + private var scope: CoroutineScope? = null + + @CallSuper + override fun start() { + scope = store.flowScoped { flow -> + onState(flow) + } + } + + @CallSuper + override fun stop() { + scope?.cancel() + } + + /** + * A callback that is invoked when a [Flow] on the [store] is available to use. + */ + abstract suspend fun onState(flow: Flow<S>) +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt new file mode 100644 index 0000000000..69ea7dd52b --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.internal + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Builder to lazily create a function that will invoke the chain of [middleware] and finally the + * [reducer]. + */ +internal class ReducerChainBuilder<S : State, A : Action>( + private val storeThreadFactory: StoreThreadFactory, + private val reducer: Reducer<S, A>, + private val middleware: List<Middleware<S, A>>, +) { + private var chain: ((A) -> Unit)? = null + + /** + * Returns a function that will invoke the chain of [middleware] and the [reducer] for the given + * [Store]. + */ + fun get(store: Store<S, A>): (A) -> Unit { + chain?.let { return it } + + return build(store).also { + chain = it + } + } + + private fun build(store: Store<S, A>): (A) -> Unit { + val context = object : MiddlewareContext<S, A> { + override val state: S + get() = store.state + + override fun dispatch(action: A) { + get(store).invoke(action) + } + + override val store: Store<S, A> + get() = store + } + + var chain: (A) -> Unit = { action -> + val state = reducer(store.state, action) + store.transitionTo(state) + } + + val threadCheck: Middleware<S, A> = { _, next, action -> + storeThreadFactory.assertOnThread() + next(action) + } + + (middleware.reversed() + threadCheck).forEach { middleware -> + val next = chain + chain = { action -> middleware(context, next, action) } + } + + return chain + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt new file mode 100644 index 0000000000..fb9e53d7da --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.internal + +import mozilla.components.lib.state.Store +import mozilla.components.support.base.utils.NamedThreadFactory +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory + +/** + * Custom [ThreadFactory] implementation wrapping [Executors.defaultThreadFactory]/[NamedThreadFactory] + * that allows asserting whether a caller is on the created thread. + * + * For usage with [Executors.newSingleThreadExecutor]: Only the last created thread is kept and + * compared when [assertOnThread] is called. + * + * @param threadNamePrefix Optional prefix with which to name threads for the [Store]. If not provided, + * the naming scheme will be deferred to [Executors.defaultThreadFactory] + */ +internal class StoreThreadFactory( + threadNamePrefix: String?, +) : ThreadFactory { + @Volatile + private var thread: Thread? = null + + private val actualFactory = if (threadNamePrefix != null) { + NamedThreadFactory(threadNamePrefix) + } else { + Executors.defaultThreadFactory() + } + + override fun newThread(r: Runnable): Thread { + return actualFactory.newThread(r).also { + thread = it + } + } + + /** + * Asserts that the calling thread is the thread of this [StoreDispatcher]. Otherwise throws an + * [IllegalThreadStateException]. + */ + fun assertOnThread() { + val currentThread = Thread.currentThread() + val currentThreadId = currentThread.id + val expectedThreadId = thread?.id + + if (currentThreadId == expectedThreadId) { + return + } + + throw IllegalThreadStateException( + "Expected `store` thread, but running on thread `${currentThread.name}`. " + + "Leaked MiddlewareContext or did you mean to use `MiddlewareContext.store.dispatch`?", + ) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt new file mode 100644 index 0000000000..34adcf511d --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.ext.joinBlocking +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.shadows.ShadowLooper + +@RunWith(AndroidJUnit4::class) +class StoreExceptionTest { + // This test is in a separate class because it needs to run with Robolectric (different runner, slower) while all + // other tests only need a Java VM (fast). + @Test(expected = StoreException::class) + fun `Exception in reducer will be rethrown on main thread`() { + val throwingReducer: (TestState, TestAction) -> TestState = { _, _ -> + throw IllegalStateException("Not reducing today") + } + + val store = Store(TestState(counter = 23), throwingReducer) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + // Wait for the main looper to process the re-thrown exception. + ShadowLooper.idleMainLooper() + + Assert.fail() + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt new file mode 100644 index 0000000000..715e3e55ba --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt @@ -0,0 +1,311 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +import mozilla.components.support.test.ext.joinBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class StoreTest { + @Test + fun `Dispatching Action executes reducers and creates new State`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertEquals(24, store.state.counter) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(22, store.state.counter) + } + + @Test + fun `Observer gets notified about state changes`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + store.observeManually { state -> observedValue = state.counter }.also { + it.resume() + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertEquals(24, observedValue) + } + + @Test + fun `Observer gets initial value before state changes`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + store.observeManually { state -> observedValue = state.counter }.also { + it.resume() + } + + assertEquals(23, observedValue) + } + + @Test + fun `Observer does not get notified if state does not change`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateChangeObserved = false + + store.observeManually { stateChangeObserved = true }.also { + it.resume() + } + + // Initial state observed + assertTrue(stateChangeObserved) + stateChangeObserved = false + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertFalse(stateChangeObserved) + } + + @Test + fun `Observer does not get notified after unsubscribe`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + val subscription = store.observeManually { state -> + observedValue = state.counter + }.also { + it.resume() + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertEquals(24, observedValue) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(23, observedValue) + + subscription.unsubscribe() + + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(23, observedValue) + assertEquals(22, store.state.counter) + } + + @Test + fun `Middleware chain gets executed in order`() { + val incrementMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + if (action == TestAction.DoNothingAction) { + store.dispatch(TestAction.IncrementAction) + } + + next(action) + } + + val doubleMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + if (action == TestAction.DoNothingAction) { + store.dispatch(TestAction.DoubleAction) + } + + next(action) + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf( + incrementMiddleware, + doubleMiddleware, + ), + ) + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertEquals(2, store.state.counter) + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertEquals(6, store.state.counter) + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertEquals(14, store.state.counter) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(13, store.state.counter) + } + + @Test + fun `Middleware can intercept actions`() { + val interceptingMiddleware: Middleware<TestState, TestAction> = { _, _, _ -> + // Do nothing! + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(interceptingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, store.state.counter) + } + + @Test + fun `Middleware can rewrite actions`() { + val rewritingMiddleware: Middleware<TestState, TestAction> = { _, next, _ -> + next(TestAction.DecrementAction) + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(rewritingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-1, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-2, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-3, store.state.counter) + } + + @Test + fun `Middleware can intercept and dispatch other action instead`() { + val rewritingMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + if (action == TestAction.IncrementAction) { + store.dispatch(TestAction.DecrementAction) + } else { + next(action) + } + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(rewritingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-1, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-2, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-3, store.state.counter) + } + + @Test + fun `Middleware sees state before and after reducing`() { + var countBefore = -1 + var countAfter = -1 + + val observingMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + countBefore = store.state.counter + next(action) + countAfter = store.state.counter + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(observingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, countBefore) + assertEquals(1, countAfter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(1, countBefore) + assertEquals(2, countAfter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(2, countBefore) + assertEquals(3, countAfter) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + assertEquals(3, countBefore) + assertEquals(2, countAfter) + } + + @Test + fun `Middleware can catch exceptions in reducer`() { + var caughtException: Exception? = null + + val catchingMiddleware: Middleware<TestState, TestAction> = { _, next, action -> + try { + next(action) + } catch (e: Exception) { + caughtException = e + } + } + + val store = Store( + TestState(counter = 0), + { _: State, _: Action -> throw IOException() }, + listOf(catchingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertNotNull(caughtException) + assertTrue(caughtException is IOException) + } +} + +fun reducer(state: TestState, action: TestAction): TestState = when (action) { + is TestAction.IncrementAction -> state.copy(counter = state.counter + 1) + is TestAction.DecrementAction -> state.copy(counter = state.counter - 1) + is TestAction.SetValueAction -> state.copy(counter = action.value) + is TestAction.DoubleAction -> state.copy(counter = state.counter * 2) + is TestAction.DoNothingAction -> state +} + +data class TestState( + val counter: Int, +) : State + +sealed class TestAction : Action { + object IncrementAction : TestAction() + object DecrementAction : TestAction() + object DoNothingAction : TestAction() + object DoubleAction : TestAction() + data class SetValueAction(val value: Int) : TestAction() +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt new file mode 100644 index 0000000000..b86e4485f4 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt @@ -0,0 +1,301 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.setMain +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +class FragmentKtTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + @Synchronized + fun `consumeFrom reads states from store`() { + val fragment = mock<Fragment>() + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(owner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFrom(store) { state -> + receivedValue = state.counter + latch.countDown() + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + // View gets detached + onAttachListener.value.onViewDetachedFromWindow(view) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @Synchronized + fun `consumeFrom does not run when fragment is detached`() { + val fragment = mock<Fragment>() + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(owner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFrom(store) { state -> + receivedValue = state.counter + latch.countDown() + } + + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(23, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + doReturn(null).`when`(fragment).activity + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(28, receivedValue) + } + + @Test + fun `consumeFlow - reads states from store`() { + val fragment = mock<Fragment>() + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(owner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFlow( + from = store, + owner = owner, + ) { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + // View gets detached + onAttachListener.value.onViewDetachedFromWindow(view) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + fun `consumeFlow - uses fragment as lifecycle owner by default`() { + val fragment = mock<Fragment>() + val fragmentLifecycleOwner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + val view = mock<View>() + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(fragmentLifecycleOwner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFlow( + from = store, + ) { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + fragmentLifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + } + + @Test + fun `consumeFlow - creates flow synchronously`() { + val fragment = mock<Fragment>() + val fragmentLifecycle = mock<LifecycleRegistry>() + val view = mock<View>() + val store = Store(TestState(counter = 23), ::reducer) + + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(fragmentLifecycle).`when`(fragment).lifecycle + doReturn(view).`when`(fragment).view + + // Verify that we create the flow even if no other coroutine runs past this point + val noopDispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + // NOOP + } + } + Dispatchers.setMain(noopDispatcher) + fragment.consumeFlow(store) { flow -> + flow.collect { } + } + + // Only way to verify that store.flow was called without triggering the channelFlow + // producer and in this test we want to make sure we call store.flow before the flow + // is "produced." + verify(fragmentLifecycle).addObserver(any()) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt new file mode 100644 index 0000000000..c52bdb032e --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt @@ -0,0 +1,572 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.app.Activity +import android.os.Looper +import android.os.Looper.getMainLooper +import android.view.View +import android.view.WindowManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +@OptIn(DelicateCoroutinesApi::class) // GlobalScope usage. +class StoreExtensionsKtTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + fun `Observer will not get registered if lifecycle is already destroyed`() = runTestOnMain { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + // We cannot set initial DESTROYED state for LifecycleRegistry + // so we simulate lifecycle getting destroyed. + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + + store.observe(owner) { stateObserved = true } + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertFalse(stateObserved) + } + + @Test + fun `Observer will get unregistered if lifecycle gets destroyed`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + store.observe(owner) { stateObserved = true } + assertTrue(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } + + @Test + fun `non-destroy lifecycle changes do not affect observer registration`() { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + // Observer does not get invoked since lifecycle is not started + var stateObserved = false + store.observe(owner) { stateObserved = true } + assertFalse(stateObserved) + + // CREATED: Observer does still not get invoked + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.CREATED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + + // STARTED: Observer gets initial state and observers updates + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + // RESUMED: Observer continues to get updates + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.RESUMED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + // CREATED: Not observing anymore + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.CREATED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + + // DESTROYED: Not observing + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi // Channel + fun `Reading state updates from channel`() = runTestOnMain { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val channel = store.channel(owner) + + val job = launch { + channel.consumeEach { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + job.cancelAndJoin() + assertTrue(channel.isClosedForReceive) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test(expected = IllegalArgumentException::class) + @ExperimentalCoroutinesApi // Channel + fun `Creating channel throws if lifecycle is already DESTROYED`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + // We cannot set initial DESTROYED state for LifecycleRegistry + // so we simulate lifecycle getting destroyed. + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + store.channel(owner) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state updates from Flow with lifecycle owner`() = runTestOnMain { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val flow = store.flow(owner) + + val job = coroutinesTestRule.scope.launch { + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + job.cancelAndJoin() + + // Receiving nothing anymore since coroutine is cancelled + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @ExperimentalCoroutinesApi + fun `Subscription is not added if owner destroyed before flow created`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + val latch = CountDownLatch(1) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + val flow = store.flow(owner) + GlobalScope.launch { + flow.collect { + latch.countDown() + } + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertTrue(store.subscriptions.isEmpty()) + } + + @Test + @ExperimentalCoroutinesApi + fun `Subscription is not added if owner destroyed before flow produced`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + val latch = CountDownLatch(1) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val flow = store.flow(owner) + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + GlobalScope.launch { + flow.collect { + latch.countDown() + } + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertTrue(store.subscriptions.isEmpty()) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state updates from Flow without lifecycle owner`() = runTestOnMain { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val flow = store.flow() + + val job = GlobalScope.launch { + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Receiving immediately + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(23, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + + latch = CountDownLatch(1) + + job.cancelAndJoin() + + // Receiving nothing anymore since coroutine is cancelled + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state from scoped flow without lifecycle owner`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val scope = store.flowScoped() { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Receiving immediately + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(23, receivedValue) + + // Updating state: Nothing received yet. + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + + scope.cancel() + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state from scoped flow with lifecycle owner`() { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val scope = store.flowScoped(owner) { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + latch = CountDownLatch(1) + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + + scope.cancel() + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + fun `Observer registered with observeForever will get notified about state changes`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + store.observeForever { state -> observedValue = state.counter } + assertEquals(23, observedValue) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(24, observedValue) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + assertEquals(23, observedValue) + } + + @Test + fun `Observer bound to view will get unsubscribed if view gets detached`() { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val view = View(testContext) + activity.windowManager.addView(view, WindowManager.LayoutParams(100, 100)) + shadowOf(getMainLooper()).idle() + + assertTrue(view.isAttachedToWindow) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + store.observe(view) { stateObserved = true } + assertTrue(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + activity.windowManager.removeView(view) + shadowOf(getMainLooper()).idle() + assertFalse(view.isAttachedToWindow) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } + + @Test + fun `Observer bound to view will not get notified about state changes until the view is attached`() = runTestOnMain { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val view = View(testContext) + + assertFalse(view.isAttachedToWindow) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + store.observe(view) { stateObserved = true } + assertFalse(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + + activity.windowManager.addView(view, WindowManager.LayoutParams(100, 100)) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(view.isAttachedToWindow) + assertTrue(stateObserved) + + stateObserved = false + store.observe(view) { stateObserved = true } + assertTrue(stateObserved) + + stateObserved = false + store.observe(view) { stateObserved = true } + assertTrue(stateObserved) + + activity.windowManager.removeView(view) + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(view.isAttachedToWindow) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } +} + +internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + val lifecycleRegistry = LifecycleRegistry(this).apply { + currentState = initialState + } + + override val lifecycle: Lifecycle = lifecycleRegistry +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt new file mode 100644 index 0000000000..6dfde6f9fa --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doNothing +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +class ViewKtTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + @Synchronized + fun `consumeFrom reads states from store`() { + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + + view.consumeFrom(store, owner) { state -> + receivedValue = state.counter + latch.countDown() + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + // View gets detached + onAttachListener.value.onViewDetachedFromWindow(view) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt new file mode 100644 index 0000000000..5173ddc39e --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.helpers + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class AbstractBindingTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + fun `binding onState is invoked when a flow is created`() { + val store = Store( + TestState(counter = 0), + ::reducer, + ) + + val binding = TestBinding(store) + + assertFalse(binding.invoked) + + binding.start() + + assertTrue(binding.invoked) + } + + @Test + fun `binding has no state changes when only stop is invoked`() { + val store = Store( + TestState(counter = 0), + ::reducer, + ) + + val binding = TestBinding(store) + + assertFalse(binding.invoked) + + binding.stop() + + assertFalse(binding.invoked) + } + + @Test + fun `binding does not get state updates after stopped`() { + val store = Store( + TestState(counter = 0), + ::reducer, + ) + + var counter = 0 + + val binding = TestBinding(store) { + counter++ + // After we stop, we shouldn't get updates for the third action dispatched. + if (counter >= 3) { + fail() + } + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + binding.start() + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + binding.stop() + + store.dispatch(TestAction.IncrementAction).joinBlocking() + } +} + +@ExperimentalCoroutinesApi +class TestBinding( + store: Store<TestState, TestAction>, + private val onStateUpdated: (TestState) -> Unit = {}, +) : AbstractBinding<TestState>(store) { + var invoked = false + override suspend fun onState(flow: Flow<TestState>) { + invoked = true + flow.collect { onStateUpdated(it) } + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |