diff --git a/.github/workflows/crashlytics-e2e.yml b/.github/workflows/crashlytics-e2e.yml new file mode 100644 index 00000000000..67e0bd9c790 --- /dev/null +++ b/.github/workflows/crashlytics-e2e.yml @@ -0,0 +1,47 @@ +name: Firebase Crashlytics E2E Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + +env: + CRASHLYTICS_E2E_GOOGLE_SERVICES: ${{ secrets.SESSIONS_E2E_GOOGLE_SERVICES }} + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout firebase-crashlytics + uses: actions/checkout@v4.1.1 + + - name: Set up JDK 17 + uses: actions/setup-java@v4.1.0 + with: + java-version: "17" + distribution: "temurin" + cache: gradle + + - name: Add google-services.json + run: | + echo $CRASHLYTICS_E2E_GOOGLE_SERVICES | base64 -d > google-services.json + + - uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Run Crashlytics end-to-end tests + env: + FTL_RESULTS_BUCKET: crashlytics-test-results + run: | + ./gradlew :firebase-crashlytics:test-app:deviceCheck withErrorProne -PtargetBackend="prod" -PtriggerCrashes diff --git a/firebase-crashlytics/firebase-crashlytics.gradle b/firebase-crashlytics/firebase-crashlytics.gradle index 63b9da33040..1497187495f 100644 --- a/firebase-crashlytics/firebase-crashlytics.gradle +++ b/firebase-crashlytics/firebase-crashlytics.gradle @@ -99,6 +99,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.truth) testImplementation(project(":integ-testing")) + testImplementation("io.mockk:mockk:1.13.8") + androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) diff --git a/firebase-crashlytics/test-app/.gitignore b/firebase-crashlytics/test-app/.gitignore new file mode 100644 index 00000000000..4ed65ee3787 --- /dev/null +++ b/firebase-crashlytics/test-app/.gitignore @@ -0,0 +1 @@ +**/google-services.json diff --git a/firebase-crashlytics/test-app/README.md b/firebase-crashlytics/test-app/README.md new file mode 100644 index 00000000000..159c3caf0a8 --- /dev/null +++ b/firebase-crashlytics/test-app/README.md @@ -0,0 +1,25 @@ +# Firebase Crashlytics Test App + +## Setup + +Download the `google-services.json` file +from [Firebase Console](https://console.firebase.google.com/) (for whatever Firebase project you +have or want to integrate the `test-app`) and store it under the current directory. + +Note: The [Package name](https://firebase.google.com/docs/android/setup#register-app) for your app +created on the Firebase Console (for which the `google-services.json` is downloaded) must match +the [applicationId](https://developer.android.com/studio/build/application-id.html) declared in +the `test-app/test-app.gradle.kts` for the app to link to Firebase. + +## Running + +Run the test app directly from Android Studio by selecting and running +the `firebase-crashlytics.test-app` run configuration. + +## Terminal + +Alternatively, you can run the test app from the terminal using the following command: + +```bash + ./gradlew :firebase-crashlytics:test-app:connectedAndroidTest --info +``` diff --git a/firebase-crashlytics/test-app/multidex-config.pro b/firebase-crashlytics/test-app/multidex-config.pro new file mode 100644 index 00000000000..61f1dfa8c4f --- /dev/null +++ b/firebase-crashlytics/test-app/multidex-config.pro @@ -0,0 +1 @@ +-keep class com.google.firebase.** { *; } diff --git a/firebase-crashlytics/test-app/src/androidTest/AndroidManifest.xml b/firebase-crashlytics/test-app/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000000..e417fdf07de --- /dev/null +++ b/firebase-crashlytics/test-app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/firebase-crashlytics/test-app/src/androidTest/kotlin/com/google/firebase/testing/crashlytics/FirebaseCrashlyticsIntegrationTest.kt b/firebase-crashlytics/test-app/src/androidTest/kotlin/com/google/firebase/testing/crashlytics/FirebaseCrashlyticsIntegrationTest.kt new file mode 100644 index 00000000000..9fc356ec61b --- /dev/null +++ b/firebase-crashlytics/test-app/src/androidTest/kotlin/com/google/firebase/testing/crashlytics/FirebaseCrashlyticsIntegrationTest.kt @@ -0,0 +1,441 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.crashlytics + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.google.common.truth.Truth.assertThat +import java.util.Locale +import java.util.regex.Pattern +import org.junit.After +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith + +const val APP_NAME = "com.google.firebase.testing.crashlytics" + +/** + * Integration tests for Firebase Crashlytics scenarios. Each test: 1) Launches the app 2) Clicks a + * specific button that sets user ID & triggers crash/no-crash logic 3) If there's a crash, relaunch + * the app to send the crash 4) Then read the user ID from the textView (after crash & relaunch) 5) + * Logs a console link for manual verification + */ +@RunWith(AndroidJUnit4::class) +class FirebaseCrashlyticsIntegrationTest { + + private lateinit var device: UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + } + + @After + fun cleanup() { + // Force-stop the app after each test to start fresh next time + Runtime.getRuntime().exec(arrayOf("am", "force-stop", APP_NAME)) + } + + /** Helper method: read logcat (only used to verify Crashlytics init in one test). */ + private fun readLogcat(tagFilter: String): Boolean { + val logs = mutableListOf() + val process = Runtime.getRuntime().exec("logcat -d") + process.inputStream.bufferedReader().useLines { lines -> + lines.filter { it.contains(tagFilter) }.forEach { logs.add(it) } + } + return logs.any { it.contains(tagFilter) } + } + + /** Helper: Build Crashlytics console search URL for a given userId. */ + private fun getCrashlyticsSearchUrl(userId: String): String { + return "https://console.firebase.google.com/project/crashlytics-e2e/" + + "crashlytics/app/android:com.google.firebase.testing.crashlytics/search" + + "?time=last-seven-days&types=crash&q=$userId" + } + + /** Helper: Launch the app from the home screen. */ + private fun launchApp() { + device.pressHome() + device.wait(Until.hasObject(By.pkg(device.launcherPackageName).depth(0)), LAUNCH_TIMEOUT) + + val context = ApplicationProvider.getApplicationContext() + val intent = + context.packageManager.getLaunchIntentForPackage(APP_NAME)?.apply { + addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + context.startActivity(intent) + + device.wait(Until.hasObject(By.pkg(TEST_APP_PACKAGE).depth(0)), LAUNCH_TIMEOUT) + device.waitForIdle() + } + + /** + * Helper: Find a button by text and click it. The app's buttons appear to be uppercase, so we do + * uppercase() to match. + */ + private fun clickButton(buttonText: String) { + val uppercaseButtonText = buttonText.uppercase(Locale.getDefault()) + device.wait(Until.hasObject(By.text(uppercaseButtonText).depth(0)), TRANSITION_TIMEOUT) + val buttonObj = device.findObject(By.text(uppercaseButtonText).clazz("android.widget.Button")) + if (buttonObj == null) { + fail("Could not locate button with text $buttonText") + } + buttonObj.click() + } + + /** + * Helper: Read the user ID from the textView that displays it in the app. e.g., "UserId: + * SomeValue" Because we are reading AFTER the crash (app is relaunched), the app persists the + * user ID via SharedPreferences. + */ + private fun readDisplayedUserId(): String { + // Wait up to 3 seconds for a TextView that matches the pattern "UserId: ..." + device.wait(Until.hasObject(By.text(Pattern.compile("UserId:.*"))), 3000) + + // Find the object using the same pattern + val userIdObj = device.findObject(By.text(Pattern.compile("UserId:.*"))) + + // If found, remove the "UserId: " prefix + return userIdObj?.text?.substringAfter("UserId: ") ?: "UNKNOWN_USER_ID" + } + + /** Helper: Read the "Did crash previously?" text from the app. */ + private fun readDidCrashPreviouslyText(): String { + // Wait for up to 3 seconds for the text + device.wait(Until.hasObject(By.text(Pattern.compile("HasCrashed:.*"))), 3000) + + // Find the object by resource ID + val didCrashObj = device.findObject(By.text(Pattern.compile("HasCrashed:.*"))) + return didCrashObj.text ?: "(unknown)" + } + + // --------------------------------------------------------------------------- + // Shared / Common Tests + // --------------------------------------------------------------------------- + + @Test + fun shared_Initialize_Crashlytics() { + launchApp() + clickButton("Shared_Initialize_Crashlytics") + + // This test does NOT crash, so we read the ID in the same session + val userId = readDisplayedUserId() + + // Check logs to confirm Crashlytics initialization + val crashlyticsInitialized = readLogcat("Initializing Firebase Crashlytics") + assertThat(crashlyticsInitialized).isTrue() + + Log.i( + "TestInfo", + "Check Crashlytics initialization. userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun shared_Generate_Crash() { + launchApp() + clickButton("Shared_Generate_Crash") + + // Crash => relaunch + launchApp() + // Now read the user ID after the crash + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Verify crash in console for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun shared_Verify_Crash() { + launchApp() + clickButton("Shared_Verify_Crash") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i("TestInfo", "After crashing, verify userId=$userId => ${getCrashlyticsSearchUrl(userId)}") + } + + @Test + fun shared_Verify_No_Crash() { + launchApp() + clickButton("Shared_Verify_No_Crash") + + // No crash, so read the user ID now + val userId = readDisplayedUserId() + + Log.i("TestInfo", "Verify NO crash for userId=$userId => ${getCrashlyticsSearchUrl(userId)}") + } + + // --------------------------------------------------------------------------- + // Core Scenario + // --------------------------------------------------------------------------- + + @Test + fun firebaseCore_Fatal_Error() { + launchApp() + clickButton("FirebaseCore_Fatal_Error") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i("TestInfo", "Check console for userId=$userId => ${getCrashlyticsSearchUrl(userId)}") + } + + // --------------------------------------------------------------------------- + // Public APIs + // --------------------------------------------------------------------------- + + @Test + fun public_API_Log() { + launchApp() + clickButton("Public_API_Log") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Verify custom logs in Crashlytics for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun public_API_SetCustomValue() { + launchApp() + clickButton("Public_API_SetCustomValue") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Verify custom keys in Crashlytics for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun public_API_SetUserID() { + launchApp() + clickButton("Public_API_SetUserID") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Verify user ID in Crashlytics: userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Ignore("This test is temporarily ignored due workarounds for TestLab compatibility.") + @Test + fun public_API_DidCrashPreviously() { + launchApp() + + // Close the app + closeAppFromRecents(device) + + launchApp() + val hasCrashedText = readDidCrashPreviouslyText() + assertThat(hasCrashedText).contains("HasCrashed: false") + + clickButton("Public_API_DidCrashPreviously") + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + val hasCrashedTextAfter = readDidCrashPreviouslyText() + assertThat(hasCrashedTextAfter).contains("HasCrashed: true") + + Log.i( + "TestInfo", + "public_API_DidCrashPreviously => userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun public_API_RecordException() { + launchApp() + clickButton("Public_API_RecordException") + + // This test does NOT crash, so read user ID in the same session + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Check Crashlytics non-fatal events for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + // --------------------------------------------------------------------------- + // Data Collection APIs + // --------------------------------------------------------------------------- + + @Test + fun dataCollection_Default() { + launchApp() + clickButton("DataCollection_Default") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Check default data collection, userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun dataCollection_Firebase_Off() { + launchApp() + clickButton("DataCollection_Firebase_Off") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Verify no crash is reported for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun dataCollection_Crashlytics_Off() { + launchApp() + clickButton("DataCollection_Crashlytics_Off") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i( + "TestInfo", + "Crash should not be uploaded. userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun dataCollection_Crashlytics_Off_Then_On() { + launchApp() + clickButton("DataCollection_Crashlytics_Off_Then_On") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + // In the real scenario, you'd setCrashlyticsCollectionEnabled(true) after the relaunch + Log.i( + "TestInfo", + "Check if previously cached crash for userId=$userId is now sent => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun dataCollection_Crashlytics_Off_Then_Send() { + launchApp() + clickButton("DataCollection_Crashlytics_Off_Then_Send") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + // e.g. FirebaseCrashlytics.getInstance().sendUnsentReports() + Log.i( + "TestInfo", + "Check if crash is now sent for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun dataCollection_Crashlytics_Off_Then_Delete() { + launchApp() + clickButton("DataCollection_Crashlytics_Off_Then_Delete") + + // Crash => relaunch + launchApp() + val userId = readDisplayedUserId() + + // e.g. deleteUnsentReports() + sendUnsentReports() + Log.i( + "TestInfo", + "Confirm no crash is uploaded for userId=$userId => ${getCrashlyticsSearchUrl(userId)}" + ) + } + + @Test + fun interoperability_IID() { + launchApp() + clickButton("Interoperability_IID") + + // The app crashes => relaunch + launchApp() + val userId = readDisplayedUserId() + + Log.i("TestInfo", "Interoperability_IID. userId=$userId => ${getCrashlyticsSearchUrl(userId)}") + } + + // --------------------------------------------------------------------------- + // Navigation & UI Helpers + // --------------------------------------------------------------------------- + + private fun closeAppFromRecents( + device: UiDevice, + ) { + // 1) Open Recent Apps + device.pressRecentApps() + + // 2) Wait a moment for Recents to appear + Thread.sleep(1000) + + // 3) Swipe upward from the middle of the screen + // to about a quarter of the screen height (adjust as needed). + val startX = device.displayWidth / 2 + val startY = device.displayHeight / 2 + val endX = device.displayWidth / 2 + val endY = device.displayHeight / 4 + + // 'steps' parameter controls the speed/animation of the swipe + // Larger = slower swipe, smaller = faster + device.swipe(startX, startY, endX, endY, 5) + + // Wait a bit to ensure the system completes the action + Thread.sleep(1000) + } + + companion object { + private const val TEST_APP_PACKAGE = "com.google.firebase.testing.crashlytics" + private const val LAUNCH_TIMEOUT = 5_000L + private const val TRANSITION_TIMEOUT = 1_000L + } +} diff --git a/firebase-crashlytics/test-app/src/main/AndroidManifest.xml b/firebase-crashlytics/test-app/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..f1f53c18486 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/BaseActivity.kt b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/BaseActivity.kt new file mode 100644 index 00000000000..7a3a2856b08 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/BaseActivity.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.crashlytics + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.google.firebase.FirebaseApp + +open class BaseActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PreFirebaseProvider.initialize() + FirebaseApp.initializeApp(this) + } +} diff --git a/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/FirstFragment.kt b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/FirstFragment.kt new file mode 100644 index 00000000000..3e3174c74ac --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/FirstFragment.kt @@ -0,0 +1,245 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.crashlytics + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.installations.FirebaseInstallations +import com.google.firebase.testing.crashlytics.databinding.FragmentFirstBinding + +class FirstFragment : Fragment() { + + private var _binding: FragmentFirstBinding? = null + private val binding + get() = _binding!! + + // Single Crashlytics instance + private val crashlytics = FirebaseCrashlytics.getInstance() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFirstBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // 1) Restore and display any previously saved user ID + loadAndApplyUserId() + + // 1b) Has the app crashed before? + loadAndApplyHasCrashed() + + // 2) Set up buttons. Each button sets a NEW user ID, saves it, then performs its logic. + binding.buttonSharedInitializeCrashlytics.setOnClickListener { + val userId = "SharedInitialize_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + crashlytics.log("Shared_Initialize_Crashlytics button clicked. userId=$userId") + // Typically, Crashlytics auto-initializes. We do nothing special otherwise. + } + + binding.buttonSharedGenerateCrash.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Shared_Generate_Crash" + val userId = "SharedGenerate_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("Test: Shared_Generate_Crash") + } + + binding.buttonSharedVerifyCrash.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Shared_Verify_Crash" + val userId = "SharedVerifyCrash_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + crashlytics.log("Shared_Verify_Crash log before crash. userId=$userId") + crashlytics.setCustomKey("SharedVerifyCrashKey", "SomeValue") + Thread.sleep(1_000) + throw RuntimeException("Test: Shared_Verify_Crash") + } + + binding.buttonSharedVerifyNoCrash.setOnClickListener { + val userId = "SharedVerifyNoCrash_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + crashlytics.log("Shared_Verify_No_Crash clicked, but not crashing. userId=$userId") + // No crash, so user remains in the app. + } + + binding.buttonFirebasecoreFatalError.setOnClickListener { + PreFirebaseProvider.expectedMessage = "FirebaseCore_Fatal_Error" + val userId = "FirebaseCoreFatal_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("FirebaseCore_Fatal_Error has occurred.") + } + + binding.buttonPublicApiLog.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Public_API_Log" + val userId = "PublicApiLog_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + crashlytics.log("Custom log message 1 (Public_API_Log) userId=$userId") + crashlytics.log("Custom log message 2 (Public_API_Log)") + Thread.sleep(1_000) + throw RuntimeException("Public_API_Log crash") + } + + binding.buttonPublicApiSetcustomvalue.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Public_API_SetCustomValue" + val userId = "PublicApiSetCustomValue_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + crashlytics.setCustomKey("key", "value") + crashlytics.setCustomKey("number", 42) + throw RuntimeException("Public_API_SetCustomValue crash") + } + + binding.buttonPublicApiSetuserid.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Public_API_SetUserID" + val userId = "PublicApiSetUserID_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("Public_API_SetUserID crash") + } + + binding.buttonPublicApiDidcrashpreviously.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Public_API_DidCrashPreviously" + val userId = "DidCrashPreviously_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("Public_API_DidCrashPreviously crash") + } + + binding.buttonPublicApiRecordexception.setOnClickListener { + val userId = "PublicApiRecordException_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + // Record multiple non-fatal exceptions, but do NOT crash. + crashlytics.recordException(RuntimeException("public_API_RecordException: non-fatal 1")) + crashlytics.recordException(RuntimeException("public_API_RecordException: non-fatal 2")) + crashlytics.log( + "Public_API_RecordException: recorded two non-fatals, no crash. userId=$userId" + ) + } + + binding.buttonDatacollectionDefault.setOnClickListener { + PreFirebaseProvider.expectedMessage = "DataCollection_Default" + val userId = "DataCollectionDefault_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("DataCollection_Default crash") + } + + binding.buttonDatacollectionFirebaseOff.setOnClickListener { + PreFirebaseProvider.expectedMessage = "DataCollection_Firebase_Off" + val userId = "DataCollectionFirebaseOff_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("DataCollection_Firebase_Off crash") + } + + binding.buttonDatacollectionCrashlyticsOff.setOnClickListener { + PreFirebaseProvider.expectedMessage = "DataCollection_Crashlytics_Off" + val userId = "DataCollectionCrashlyticsOff_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("DataCollection_Crashlytics_Off crash") + } + + binding.buttonDatacollectionOffThenOn.setOnClickListener { + PreFirebaseProvider.expectedMessage = "DataCollection_Crashlytics_Off_Then_On" + val userId = "DataCollectionOffThenOn_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("DataCollection_Crashlytics_Off_Then_On crash") + } + + binding.buttonDatacollectionOffThenSend.setOnClickListener { + PreFirebaseProvider.expectedMessage = "DataCollection_Crashlytics_Off_Then_Send" + val userId = "DataCollectionOffThenSend_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("DataCollection_Crashlytics_Off_Then_Send crash") + } + + binding.buttonDatacollectionOffThenDelete.setOnClickListener { + PreFirebaseProvider.expectedMessage = "DataCollection_Crashlytics_Off_Then_Delete" + val userId = "DataCollectionOffThenDelete_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + throw RuntimeException("DataCollection_Crashlytics_Off_Then_Delete crash") + } + + binding.buttonInteroperabilityIid.setOnClickListener { + PreFirebaseProvider.expectedMessage = "Interoperability_IID" + val userId = "InteroperabilityIID_${System.currentTimeMillis()}" + saveAndApplyUserId(userId) + + FirebaseInstallations.getInstance().delete().addOnCompleteListener { + throw RuntimeException("Interoperability_IID crash after ID reset") + } + } + } + + /** + * Load the previously-saved userId (if any) from SharedPreferences, apply it to Crashlytics, and + * display it in the TextView. + */ + private fun loadAndApplyUserId() { + val prefs = requireContext().getSharedPreferences("crashlytics_prefs", Context.MODE_PRIVATE) + val savedId = prefs.getString("latest_user_id", null) + if (!savedId.isNullOrEmpty()) { + crashlytics.setUserId(savedId) + binding.currentUserIdText.text = "UserId: $savedId" + } + } + + /** Load the previously-saved "hasCrashed" flag, */ + private fun loadAndApplyHasCrashed() { + val hasCrashed = crashlytics.didCrashOnPreviousExecution() + binding.currentHasCrashed.text = "HasCrashed: $hasCrashed" + } + + /** + * Save the given userId to SharedPreferences, apply it to Crashlytics, and update the TextView + * accordingly. + */ + private fun saveAndApplyUserId(newUserId: String) { + // Save to SharedPreferences + val prefs = requireContext().getSharedPreferences("crashlytics_prefs", Context.MODE_PRIVATE) + prefs.edit().putString("latest_user_id", newUserId).apply() + + // Apply to Crashlytics and UI + crashlytics.setUserId(newUserId) + binding.currentUserIdText.text = "UserId: $newUserId" + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/MainActivity.kt b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/MainActivity.kt new file mode 100644 index 00000000000..c5e2c3ee7a5 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/MainActivity.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.crashlytics + +import android.os.Bundle +import com.google.firebase.testing.crashlytics.databinding.ActivityMainBinding + +class MainActivity : BaseActivity() { + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + } +} diff --git a/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/PreFirebaseProvider.kt b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/PreFirebaseProvider.kt new file mode 100644 index 00000000000..fc755f56f9f --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/PreFirebaseProvider.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.crashlytics + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import java.lang.Thread.UncaughtExceptionHandler +import kotlin.system.exitProcess + +class PreFirebaseProvider : ContentProvider(), UncaughtExceptionHandler { + private var defaultUncaughtExceptionHandler: UncaughtExceptionHandler? = null + + companion object { + var expectedMessage: String? = null + private var defaultUncaughtExceptionHandler: UncaughtExceptionHandler? = null + + fun initialize() { + if ( + defaultUncaughtExceptionHandler == null || + defaultUncaughtExceptionHandler !is PreFirebaseExceptionHandler + ) { + defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler( + PreFirebaseExceptionHandler(defaultUncaughtExceptionHandler) + ) + } + } + + private class PreFirebaseExceptionHandler(private val delegate: UncaughtExceptionHandler?) : + UncaughtExceptionHandler { + override fun uncaughtException(thread: Thread, throwable: Throwable) { + if (expectedMessage != null && throwable.message?.contains(expectedMessage!!) == true) { + exitProcess(0) + } else { + delegate?.uncaughtException(thread, throwable) + } + } + } + } + + override fun onCreate(): Boolean { + initialize() + return false + } + + /* Returns if the given exception contains the expectedMessage in its message. */ + private fun isExpected(throwable: Throwable): Boolean = + expectedMessage?.run { throwable.message?.contains(this) } ?: false + + override fun uncaughtException(thread: Thread, throwable: Throwable) { + if (isExpected(throwable)) { + // Exit cleanly + exitProcess(0) + } else { + // Propagate up to the default exception handler + defaultUncaughtExceptionHandler?.uncaughtException(thread, throwable) + } + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 +} diff --git a/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/TestApplication.kt b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/TestApplication.kt new file mode 100644 index 00000000000..328e400e0c8 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/kotlin/com/google/firebase/testing/crashlytics/TestApplication.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.crashlytics + +import androidx.multidex.MultiDexApplication + +class TestApplication : MultiDexApplication() {} diff --git a/firebase-crashlytics/test-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/firebase-crashlytics/test-app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000000..44a7ccb32c8 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/firebase-crashlytics/test-app/src/main/res/drawable/ic_launcher_background.xml b/firebase-crashlytics/test-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000000..3f723137ef3 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebase-crashlytics/test-app/src/main/res/drawable/ic_launcher_foreground.xml b/firebase-crashlytics/test-app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000000..2b068d11462 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/firebase-crashlytics/test-app/src/main/res/drawable/sensor_window.xml b/firebase-crashlytics/test-app/src/main/res/drawable/sensor_window.xml new file mode 100644 index 00000000000..8c9dd3f4731 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/res/drawable/sensor_window.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/firebase-crashlytics/test-app/src/main/res/layout/activity_main.xml b/firebase-crashlytics/test-app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000000..b8ce9758d24 --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/res/layout/activity_main.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/firebase-crashlytics/test-app/src/main/res/layout/activity_second.xml b/firebase-crashlytics/test-app/src/main/res/layout/activity_second.xml new file mode 100644 index 00000000000..1292d4f8d4d --- /dev/null +++ b/firebase-crashlytics/test-app/src/main/res/layout/activity_second.xml @@ -0,0 +1,67 @@ + + + + +