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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/layout/content_main.xml b/firebase-crashlytics/test-app/src/main/res/layout/content_main.xml
new file mode 100644
index 00000000000..98aa89a61d5
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/layout/content_main.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/layout/crash_widget.xml b/firebase-crashlytics/test-app/src/main/res/layout/crash_widget.xml
new file mode 100644
index 00000000000..fa7b4f6421e
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/layout/crash_widget.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/layout/crash_widget_preview.xml b/firebase-crashlytics/test-app/src/main/res/layout/crash_widget_preview.xml
new file mode 100644
index 00000000000..cb6518f2e1e
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/layout/crash_widget_preview.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/layout/fragment_first.xml b/firebase-crashlytics/test-app/src/main/res/layout/fragment_first.xml
new file mode 100644
index 00000000000..87b434626da
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/layout/fragment_first.xml
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/firebase-crashlytics/test-app/src/main/res/menu/menu_main.xml b/firebase-crashlytics/test-app/src/main/res/menu/menu_main.xml
new file mode 100644
index 00000000000..fb309495de1
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/menu/menu_main.xml
@@ -0,0 +1,26 @@
+
+
+
\ No newline at end of file
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000000..1328ee3bdea
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000000..1328ee3bdea
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
new file mode 100644
index 00000000000..acb106f3faa
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000000..c209e78ecd3
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000000..b2dfe3d1ba5
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000000..4f0f1d64e58
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000000..62b611da081
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000000..948a3070fe3
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000000..1b9a6956b3a
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000000..28d4b77f9f0
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000000..9287f508362
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000000..aa7d6427e6f
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/firebase-crashlytics/test-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000000..9126ae37cbc
Binary files /dev/null and b/firebase-crashlytics/test-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/firebase-crashlytics/test-app/src/main/res/navigation/nav_graph.xml b/firebase-crashlytics/test-app/src/main/res/navigation/nav_graph.xml
new file mode 100644
index 00000000000..cd34e0e59e8
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/navigation/nav_graph.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/firebase-crashlytics/test-app/src/main/res/values-night-v21/themes.xml b/firebase-crashlytics/test-app/src/main/res/values-night-v21/themes.xml
new file mode 100644
index 00000000000..ba22f1e300f
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/values-night-v21/themes.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/values-night/themes.xml b/firebase-crashlytics/test-app/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000000..1d40bec6a3e
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/values-night/themes.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/values/colors.xml b/firebase-crashlytics/test-app/src/main/res/values/colors.xml
new file mode 100644
index 00000000000..119d0762aa6
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/values/colors.xml
@@ -0,0 +1,25 @@
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/firebase-crashlytics/test-app/src/main/res/values/strings.xml b/firebase-crashlytics/test-app/src/main/res/values/strings.xml
new file mode 100644
index 00000000000..abb0bc50a33
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/values/strings.xml
@@ -0,0 +1,35 @@
+
+
+
+ Firebase Crashlytics Test App
+ Settings
+ First Fragment
+ Test Widget for Crashing
+ Crash!
+ Crash!
+ Non Fatal
+ ANR
+ Start Foreground Service
+ Start splitscreen - Different activity
+ Start splitscreen - Same activity
+ Next activity
+ Previous activity
+ Kill Background Processes
+ No Session Set
+ Session Id:
+ Crash with custom log
+
\ No newline at end of file
diff --git a/firebase-crashlytics/test-app/src/main/res/values/themes.xml b/firebase-crashlytics/test-app/src/main/res/values/themes.xml
new file mode 100644
index 00000000000..a92994a22c1
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/values/themes.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-crashlytics/test-app/src/main/res/xml/homescreen_widget.xml b/firebase-crashlytics/test-app/src/main/res/xml/homescreen_widget.xml
new file mode 100644
index 00000000000..913c5969e36
--- /dev/null
+++ b/firebase-crashlytics/test-app/src/main/res/xml/homescreen_widget.xml
@@ -0,0 +1,30 @@
+
+
+
diff --git a/firebase-crashlytics/test-app/test-app.gradle.kts b/firebase-crashlytics/test-app/test-app.gradle.kts
new file mode 100644
index 00000000000..c2abf7a69ad
--- /dev/null
+++ b/firebase-crashlytics/test-app/test-app.gradle.kts
@@ -0,0 +1,84 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("DEPRECATION") // App projects should still use FirebaseTestLabPlugin.
+
+import com.google.firebase.gradle.plugins.ci.device.FirebaseTestLabExtension
+import com.google.firebase.gradle.plugins.ci.device.FirebaseTestLabPlugin
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.gms.google-services")
+ id("com.google.firebase.crashlytics")
+ id("copy-google-services")
+}
+
+android {
+ val compileSdkVersion: Int by rootProject
+ val targetSdkVersion: Int by rootProject
+ val minSdkVersion: Int by rootProject
+
+ namespace = "com.google.firebase.testing.crashlytics"
+ compileSdk = compileSdkVersion
+ buildFeatures.buildConfig = true
+ defaultConfig {
+ applicationId = "com.google.firebase.testing.crashlytics"
+ minSdk = minSdkVersion
+ targetSdk = targetSdkVersion
+ versionCode = 1
+ versionName = "1.0"
+ multiDexEnabled = true
+ multiDexKeepProguard = file("multidex-config.pro")
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = "1.8" }
+ buildFeatures { viewBinding = true }
+}
+
+dependencies {
+ if (project.hasProperty("useReleasedVersions")) {
+ implementation(platform("com.google.firebase:firebase-bom:latest.release"))
+ implementation("com.google.firebase:firebase-crashlytics")
+ } else {
+ implementation(project(":firebase-crashlytics"))
+ }
+
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.core:core-ktx:1.7.0")
+ implementation("androidx.multidex:multidex:2.0.1")
+ implementation("androidx.navigation:navigation-fragment-ktx:2.4.1")
+ implementation("androidx.navigation:navigation-ui-ktx:2.4.1")
+ implementation("com.google.android.material:material:1.9.0")
+ implementation(libs.androidx.core)
+
+ androidTestImplementation("com.google.firebase:firebase-common:21.0.0")
+ androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
+ androidTestImplementation(libs.androidx.test.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.truth)
+}
+
+apply()
+
+configure {
+ device("model=panther,version=33") // Pixel7
+}
diff --git a/subprojects.cfg b/subprojects.cfg
index 3be81de81a1..3029635834f 100644
--- a/subprojects.cfg
+++ b/subprojects.cfg
@@ -22,6 +22,7 @@ firebase-config:bandwagoner
firebase-config:test-app
firebase-config-interop
firebase-crashlytics
+firebase-crashlytics:test-app
firebase-crashlytics:ktx
firebase-crashlytics-ndk
firebase-database