diff --git a/firebase-crashlytics/CHANGELOG.md b/firebase-crashlytics/CHANGELOG.md index 38311168ab3..de41c9d9e93 100644 --- a/firebase-crashlytics/CHANGELOG.md +++ b/firebase-crashlytics/CHANGELOG.md @@ -4,6 +4,7 @@ - [feature] Decorate OOM and MLK events with whether a heap dump was generated and heap dump collection is enabled [#8549] - [fixed] Fixed event timestamps on ANR events [#8612] +- [feature] Enable heap dump uploads for OOM and MLK issues on API levels 37+ [#8661] # 20.1.1 diff --git a/firebase-crashlytics/firebase-crashlytics.gradle b/firebase-crashlytics/firebase-crashlytics.gradle index 5663eae698d..8a66220ca5d 100644 --- a/firebase-crashlytics/firebase-crashlytics.gradle +++ b/firebase-crashlytics/firebase-crashlytics.gradle @@ -37,7 +37,11 @@ android { timeOutInMs 60 * 1000 } namespace "com.google.firebase.crashlytics" - compileSdkVersion 37 + compileSdk { + version = release(37) { + minorApiLevel = 2 + } + } testOptions { targetSdk project.targetSdkVersion unitTests.includeAndroidResources = true @@ -94,12 +98,15 @@ dependencies { api(project(":firebase-installations-interop")) api("com.google.firebase:firebase-measurement-connector:20.0.1") + implementation files('libs/scotty-all.jar') + // TODO(m187-repin): restore pin on com.google.android.datatransport:transport-api. implementation(project(":transport:transport-api")) // TODO(m187-repin): restore pin on com.google.android.datatransport:transport-backend-cct. implementation(project(":transport:transport-backend-cct")) // TODO(m187-repin): restore pin on com.google.android.datatransport:transport-runtime. implementation(project(":transport:transport-runtime")) + implementation(libs.androidx.annotation) compileOnly(libs.errorprone.annotations) diff --git a/firebase-crashlytics/libs/scotty-all.jar b/firebase-crashlytics/libs/scotty-all.jar new file mode 100644 index 00000000000..af264396168 Binary files /dev/null and b/firebase-crashlytics/libs/scotty-all.jar differ diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java index 65c00c49ea4..b9642d64bf4 100644 --- a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java @@ -37,6 +37,7 @@ import com.google.android.gms.tasks.TaskCompletionSource; import com.google.android.gms.tasks.Tasks; import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; import com.google.firebase.concurrent.TestOnlyExecutors; import com.google.firebase.crashlytics.internal.CrashlyticsNativeComponent; import com.google.firebase.crashlytics.internal.CrashlyticsTestCase; @@ -49,6 +50,7 @@ import com.google.firebase.crashlytics.internal.metadata.UserMetadata; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport; import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.settings.Settings; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; import com.google.firebase.crashlytics.internal.settings.TestSettings; @@ -165,6 +167,9 @@ public CrashlyticsController build() { CrashlyticsFileMarker crashMarker = new CrashlyticsFileMarker(CrashlyticsCore.CRASH_MARKER_FILE_NAME, testFileStore); + ResumableUploadMetadataStore resumableUploadMetadataStore = + new ResumableUploadMetadataStore(testContext, testFileStore); + List buildIdInfoList = new ArrayList<>(); buildIdInfoList.add(new BuildIdInfo("lib.so", "x86", "aabb")); AppData appData = @@ -178,6 +183,16 @@ public CrashlyticsController build() { "versionName", mock(DevelopmentPlatformProvider.class)); + FirebaseApp firebaseApp = mock(FirebaseApp.class); + FirebaseOptions options = + new FirebaseOptions.Builder() + .setApiKey("api-key") + .setApplicationId("applicationId") + .setProjectId("projectId") + .build(); + + when(firebaseApp.getOptions()).thenReturn(options); + final CrashlyticsController controller = new CrashlyticsController( testContext.getApplicationContext(), @@ -192,7 +207,9 @@ public CrashlyticsController build() { nativeComponent, analyticsEventLogger, mock(CrashlyticsAppQualitySessionsSubscriber.class), - crashlyticsWorkers); + crashlyticsWorkers, + resumableUploadMetadataStore, + firebaseApp); return controller; } } diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java index 927a48d83ed..a0e6c46fa0f 100644 --- a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java @@ -34,7 +34,6 @@ import android.app.ApplicationExitInfo; import android.content.Context; import android.os.Parcel; -import android.system.OsConstants; import androidx.test.filters.SdkSuppress; import androidx.test.platform.app.InstrumentationRegistry; import com.google.android.gms.tasks.Task; @@ -50,6 +49,7 @@ import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.CustomAttribute; import com.google.firebase.crashlytics.internal.persistence.CrashlyticsReportPersistence; import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender; import java.io.File; import java.io.IOException; @@ -96,6 +96,9 @@ public void setUp() { FileStore testFileStore = new FileStore(getContext()); reportMetadata = new UserMetadata(TEST_SESSION_ID, testFileStore, crashlyticsWorkers); + ResumableUploadMetadataStore resumableUploadMetadataStore = + new ResumableUploadMetadataStore(getContext(), testFileStore); + reportingCoordinator = new SessionReportingCoordinator( dataCapture, @@ -104,7 +107,8 @@ public void setUp() { logFileManager, reportMetadata, idManager, - crashlyticsWorkers); + crashlyticsWorkers, + resumableUploadMetadataStore); } @After @@ -631,30 +635,6 @@ public void testIsOom_skipsAllIrrelevantAppExitInfos() { assertFalse(isOom); } - @Test - public void testIsOom_returnsTrueOnReasonSignaled() { - ApplicationExitInfo oom = - makeApplicationExitInfo(ApplicationExitInfo.REASON_SIGNALED, 0, OsConstants.SIGKILL); - ApplicationExitInfo nonOom = makeApplicationExitInfo(ApplicationExitInfo.REASON_ANR, 0, 0); - - boolean isOom = reportingCoordinator.isOom("sessionId", List.of(nonOom, oom)); - - assertTrue(isOom); - } - - @SdkSuppress(minSdkVersion = 37) - @Test - public void testIsOom_returnTrueOnReasonLowMemory() { - ApplicationExitInfo oom = - makeApplicationExitInfo( - ApplicationExitInfo.REASON_LOW_MEMORY, /* SUBREASON_OOM_KILL= */ 30, 0); - ApplicationExitInfo nonOom = makeApplicationExitInfo(ApplicationExitInfo.REASON_ANR, 0, 0); - - boolean isOom = reportingCoordinator.isOom("sessionId", List.of(nonOom, oom)); - - assertTrue(isOom); - } - @SdkSuppress(minSdkVersion = 37) @Test public void testIsMemoryLimiterKill() { diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/network/ScottyUploaderTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/network/ScottyUploaderTest.java new file mode 100644 index 00000000000..cfcb5c8b8e7 --- /dev/null +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/network/ScottyUploaderTest.java @@ -0,0 +1,428 @@ +// Copyright 2026 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.crashlytics.internal.network; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.firebase.crashlytics.internal.CrashlyticsTestCase; +import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.uploader.client.DataStream; +import com.google.uploader.client.HttpResponse; +import com.google.uploader.client.Transfer; +import com.google.uploader.client.TransferException; +import com.google.uploader.client.TransferExceptionOrHttpResponse; +import com.google.uploader.client.TransferListener; +import com.google.uploader.client.UploadClient; +import com.google.uploader.shaded.guava.util.concurrent.Futures; +import com.google.uploader.shaded.guava.util.concurrent.ListenableFuture; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; + +public class ScottyUploaderTest extends CrashlyticsTestCase { + + private static class FakeTransfer implements Transfer { + + private TransferListener listener; + private final HttpResponse httpResponse; + private final TransferException transferException; + private final String handle; + + FakeTransfer(HttpResponse httpResponse, TransferException transferException, String handle) { + this.httpResponse = httpResponse; + this.transferException = transferException; + this.handle = handle; + } + + @Override + public ListenableFuture send() { + if (listener != null) { + if (handle != null) { + listener.onTransferHandleReady(this); + } + if (httpResponse != null) { + listener.onResponseReceived(this, httpResponse); + } + if (transferException != null) { + listener.onException(this, transferException); + } + } + + if (transferException != null) { + return Futures.immediateFuture(new TransferExceptionOrHttpResponse(transferException)); + } + return Futures.immediateFuture(new TransferExceptionOrHttpResponse(httpResponse)); + } + + @Override + public void attachListener( + TransferListener listener, int progressThresholdBytes, int progressThresholdMillis) { + this.listener = listener; + } + + @Override + public void detachListener() { + this.listener = null; + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public DataStream getRequestBody() { + return null; + } + + @Override + public long getBytesUploaded() { + return 0; + } + + @Override + public String getTransferHandle() { + return handle; + } + + @Override + public void cancel() {} + } + + private static class TestListener extends ScottyUploader.Listener { + final AtomicBoolean doneOrUnrecoverableCalled = new AtomicBoolean(false); + final AtomicBoolean handleAvailableCalled = new AtomicBoolean(false); + String availableHandle = null; + + @Override + public void onDoneOrUnrecoverable() { + doneOrUnrecoverableCalled.set(true); + } + + @Override + public void onHandleAvailable(String handle) { + handleAvailableCalled.set(true); + availableHandle = handle; + } + } + + @Test + public void testTriggeringUpload_Success() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + HttpResponse httpResponse = new HttpResponse(200, null, null); + FakeTransfer fakeTransfer = new FakeTransfer(httpResponse, null, "handle123"); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.handleAvailableCalled.get()).isTrue(); + assertThat(listener.availableHandle).isEqualTo("handle123"); + assertThat(listener.doneOrUnrecoverableCalled.get()).isTrue(); + } + + @Test + public void testTriggeringUpload_RecoverableHttpError() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + HttpResponse httpResponse = new HttpResponse(503, null, null); // Service Unavailable + FakeTransfer fakeTransfer = new FakeTransfer(httpResponse, null, null); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.handleAvailableCalled.get()).isFalse(); + assertThat(listener.doneOrUnrecoverableCalled.get()).isFalse(); + } + + @Test + public void testTriggeringUpload_UnrecoverableHttpError() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + HttpResponse httpResponse = new HttpResponse(404, null, null); // Not Found + FakeTransfer fakeTransfer = new FakeTransfer(httpResponse, null, null); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.handleAvailableCalled.get()).isFalse(); + assertThat(listener.doneOrUnrecoverableCalled.get()).isTrue(); + } + + @Test + public void testTriggeringUpload_RecoverableTransferException() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + TransferException transferException = + new TransferException(TransferException.Type.CONNECTION_ERROR, "Connection error"); + FakeTransfer fakeTransfer = new FakeTransfer(null, transferException, null); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.handleAvailableCalled.get()).isFalse(); + assertThat(listener.doneOrUnrecoverableCalled.get()).isFalse(); + } + + @Test + public void testTriggeringUpload_UnrecoverableTransferException() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + TransferException transferException = + new TransferException(TransferException.Type.BAD_URL, "Bad URL"); + FakeTransfer fakeTransfer = new FakeTransfer(null, transferException, null); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.handleAvailableCalled.get()).isFalse(); + assertThat(listener.doneOrUnrecoverableCalled.get()).isTrue(); + } + + @Test + public void testTriggeringUpload_FileDoesNotExist() { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + File nonExistentFile = new File("/path/to/non/existent/file"); + when(fileStore.getCommonFile(anyString())).thenReturn(nonExistentFile); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.handleAvailableCalled.get()).isFalse(); + assertThat(listener.doneOrUnrecoverableCalled.get()).isTrue(); + } + + @Test + public void testResumeUpload_Success() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + HttpResponse httpResponse = new HttpResponse(200, null, null); + FakeTransfer fakeTransfer = new FakeTransfer(httpResponse, null, "handle123"); + + when(client.resumeTransfer(anyString(), any(), any())).thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.resumeUpload("heapdump.perfetto", "handle123", listener); + + assertThat(listener.handleAvailableCalled.get()).isTrue(); + assertThat(listener.doneOrUnrecoverableCalled.get()).isTrue(); + } + + @Test + public void testStreamClosedAfterResponseReceived() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + HttpResponse httpResponse = new HttpResponse(200, null, null); + FakeTransfer fakeTransfer = new FakeTransfer(httpResponse, null, "handle123"); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + AtomicBoolean streamClosed = new AtomicBoolean(false); + ScottyUploader.Listener listener = + new ScottyUploader.Listener() { + @Override + public void closeStream() { + super.closeStream(); + streamClosed.set(true); + } + }; + + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(streamClosed.get()).isTrue(); + } + + @Test + public void testStreamClosedAfterException() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + TransferException transferException = + new TransferException(TransferException.Type.BAD_URL, "Bad URL"); + FakeTransfer fakeTransfer = new FakeTransfer(null, transferException, null); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + AtomicBoolean streamClosed = new AtomicBoolean(false); + ScottyUploader.Listener listener = + new ScottyUploader.Listener() { + @Override + public void closeStream() { + super.closeStream(); + streamClosed.set(true); + } + }; + + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(streamClosed.get()).isTrue(); + } + + @Test + public void testExceptionDuringCreateTransfer() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + when(client.createTransfer(anyString(), anyString(), any(), any(), anyString(), any())) + .thenThrow(new RuntimeException("Test exception")); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 1, "heapdump.perfetto", listener); + + assertThat(listener.doneOrUnrecoverableCalled.get()).isTrue(); + } + + @Test + public void testUrlParameters() throws IOException { + UploadClient client = mock(UploadClient.class); + FileStore fileStore = mock(FileStore.class); + + ScottyUploader scottyUploader = new ScottyUploader("key", "uuid", fileStore, client); + + Path heapdumpPath = Files.createTempFile("heapdump", "perfetto"); + File heapdump = heapdumpPath.toFile(); + heapdump.deleteOnExit(); + + when(fileStore.getCommonFile(anyString())).thenReturn(heapdump); + + HttpResponse httpResponse = new HttpResponse(200, null, null); + FakeTransfer fakeTransfer = new FakeTransfer(httpResponse, null, "handle123"); + + // Capture the URL + org.mockito.ArgumentCaptor urlCaptor = + org.mockito.ArgumentCaptor.forClass(String.class); + + when(client.createTransfer(urlCaptor.capture(), anyString(), any(), any(), anyString(), any())) + .thenReturn(fakeTransfer); + + TestListener listener = new TestListener(); + scottyUploader.triggerUpload("gmpAppId", "sessionId", 7, "heapdump.perfetto", listener); + + String url = urlCaptor.getValue(); + assertThat(url).contains("uploadType=media"); + assertThat(url).contains("gmp_app_id=gmpAppId"); + assertThat(url).contains("trigger_type=oom"); + assertThat(url).contains("session_id=sessionId"); + assertThat(url).contains("key=key"); + } +} diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/ResumableUploadMetadataStoreTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/ResumableUploadMetadataStoreTest.java new file mode 100644 index 00000000000..55233f10879 --- /dev/null +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/ResumableUploadMetadataStoreTest.java @@ -0,0 +1,438 @@ +// Copyright 2026 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.crashlytics.internal.persistence; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.os.ProfilingTrigger; +import androidx.test.filters.SdkSuppress; +import com.google.firebase.crashlytics.internal.CrashlyticsTestCase; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.Test; + +public class ResumableUploadMetadataStoreTest extends CrashlyticsTestCase { + FileStore fileStore = mock(FileStore.class); + Context context = getContext(); + + @Test + public void testSerializeDeserialize_noHandle() { + String sessionId = "sessionId"; + int type = ProfilingTrigger.TRIGGER_TYPE_ANOMALY; + String path = "heapdump.perfetto"; + + ResumableUploadMetadataStore.InProgressUploadMetadata metadata = + new ResumableUploadMetadataStore.InProgressUploadMetadata(sessionId, type, path); + + String serialized = ResumableUploadMetadataStore.serialize(metadata); + + assertThat(serialized).isEqualTo("sessionId,8,null,heapdump.perfetto"); + + Optional deserialized = + ResumableUploadMetadataStore.parseInProgressUploadLine(serialized); + + assertThat(deserialized).hasValue(metadata); + } + + @Test + public void testSerializeDeserialize_withHandle() { + String sessionId = "sessionId"; + int type = ProfilingTrigger.TRIGGER_TYPE_ANOMALY; + String path = "heapdump.perfetto"; + String handle = "http://some.upload.url/for/scotty"; + + ResumableUploadMetadataStore.InProgressUploadMetadata metadata = + new ResumableUploadMetadataStore.InProgressUploadMetadata(sessionId, type, handle, path); + + String serialized = ResumableUploadMetadataStore.serialize(metadata); + + assertThat(serialized) + .isEqualTo("sessionId,8,http://some.upload.url/for/scotty,heapdump.perfetto"); + + Optional deserialized = + ResumableUploadMetadataStore.parseInProgressUploadLine(serialized); + + assertThat(deserialized).hasValue(metadata); + } + + @Test + public void testSerializeDeserialize_withHandleAndCommaPath() { + String sessionId = "sessionId"; + int type = ProfilingTrigger.TRIGGER_TYPE_ANOMALY; + String path = "/,/oops/,/heapdump.perfetto"; + String handle = "http://some.upload.url/for/scotty"; + + ResumableUploadMetadataStore.InProgressUploadMetadata metadata = + new ResumableUploadMetadataStore.InProgressUploadMetadata(sessionId, type, handle, path); + + String serialized = ResumableUploadMetadataStore.serialize(metadata); + + assertThat(serialized) + .isEqualTo("sessionId,8,http://some.upload.url/for/scotty,/,/oops/,/heapdump.perfetto"); + + Optional deserialized = + ResumableUploadMetadataStore.parseInProgressUploadLine(serialized); + + assertThat(deserialized.isPresent()).isTrue(); + assertThat(deserialized.get().sessionId).isEqualTo(sessionId); + assertThat(deserialized.get().type).isEqualTo(type); + assertThat(deserialized.get().path).isEqualTo(path); + assertThat(deserialized.get().handle).isEqualTo(handle); + + assertThat(deserialized).hasValue(metadata); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testGetUpload_noDuplicatesMetadataFileDoesNotExist() throws IOException { + ResumableUploadMetadataStore.InProgressUploadMetadata existing = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId1", ProfilingTrigger.TRIGGER_TYPE_OOM, "path1"); + + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + File mockFile = mock(File.class); + when(mockFile.exists()).thenReturn(false); + + when(fileStore.getCommonFile("out-of-band-uploads")) + .thenReturn(mockFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + assertThat(store.addInProgressUpload(existing.sessionId, existing.type, existing.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing.sessionId, existing.type, existing.path)) + .isFalse(); + assertThat(store.addInProgressUpload(existing.sessionId, existing.type, existing.path)) + .isFalse(); + assertThat(store.addInProgressUpload(existing.sessionId, existing.type, existing.path)) + .isFalse(); + Optional current = store.getUpload(); + + assertThat(current.isPresent()).isTrue(); + verify(fileStore, times(2)).getCommonFile("out-of-band-uploads"); + + assertThat(current.get().sessionId).isEqualTo(existing.sessionId); + assertThat(current.get().path).isEqualTo(existing.path); + assertThat(current.get().type).isEqualTo(existing.type); + assertThat(current.get().handle).isNull(); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testGetUpload_oneItemNoHandleMetadataFileDoesNotExist() throws IOException { + ResumableUploadMetadataStore.InProgressUploadMetadata existing = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId1", ProfilingTrigger.TRIGGER_TYPE_OOM, "path1"); + + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + File mockFile = mock(File.class); + when(mockFile.exists()).thenReturn(false); + + when(fileStore.getCommonFile("out-of-band-uploads")) + .thenReturn(mockFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + assertThat(store.addInProgressUpload(existing.sessionId, existing.type, existing.path)) + .isTrue(); + Optional current = store.getUpload(); + + assertThat(current.isPresent()).isTrue(); + verify(fileStore, times(2)).getCommonFile("out-of-band-uploads"); + + assertThat(current.get().sessionId).isEqualTo(existing.sessionId); + assertThat(current.get().path).isEqualTo(existing.path); + assertThat(current.get().type).isEqualTo(existing.type); + assertThat(current.get().handle).isNull(); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testGetUpload_manyItemsNoHandleMetadataFileDoesNotExist() throws IOException { + ResumableUploadMetadataStore.InProgressUploadMetadata existing1 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId1", ProfilingTrigger.TRIGGER_TYPE_OOM, "path1"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing2 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId2", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "path2"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing3 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId3", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "path3"); + + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + File mockFile = mock(File.class); + when(mockFile.exists()).thenReturn(false); + + when(fileStore.getCommonFile("out-of-band-uploads")) + .thenReturn(mockFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + assertThat(store.addInProgressUpload(existing1.sessionId, existing1.type, existing1.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing2.sessionId, existing2.type, existing2.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing3.sessionId, existing3.type, existing3.path)) + .isTrue(); + + Optional current = store.getUpload(); + + assertThat(current.isPresent()).isTrue(); + verify(fileStore, times(4)).getCommonFile("out-of-band-uploads"); + + assertThat(current.get().sessionId).isEqualTo(existing1.sessionId); + assertThat(current.get().path).isEqualTo(existing1.path); + assertThat(current.get().type).isEqualTo(existing1.type); + assertThat(current.get().handle).isNull(); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testGetUpload_manyItemsWithHandleMetadataFileDoesNotExist() throws IOException { + ResumableUploadMetadataStore.InProgressUploadMetadata existing1 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId1", ProfilingTrigger.TRIGGER_TYPE_OOM, "path1"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing2 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId2", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "handle2", "path2"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing3 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId3", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "path3"); + + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + File mockFile = mock(File.class); + when(mockFile.exists()).thenReturn(false); + + when(fileStore.getCommonFile("out-of-band-uploads")) + .thenReturn(mockFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + assertThat(store.addInProgressUpload(existing1.sessionId, existing1.type, existing1.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing2.sessionId, existing2.type, existing2.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing3.sessionId, existing3.type, existing3.path)) + .isTrue(); + + assertThat(store.updateHandle(existing2.path, existing2.handle)).isTrue(); + + Optional current = store.getUpload(); + + assertThat(current.isPresent()).isTrue(); + verify(fileStore, times(5)).getCommonFile("out-of-band-uploads"); + + assertThat(current.get().sessionId).isEqualTo(existing2.sessionId); + assertThat(current.get().path).isEqualTo(existing2.path); + assertThat(current.get().type).isEqualTo(existing2.type); + assertThat(current.get().handle).isEqualTo(existing2.handle); + } + + @Test + public void testAddInProgressUpload_returnsFalseForNonMainProcess() + throws NoSuchFieldException, IllegalAccessException { + ResumableUploadMetadataStore.InProgressUploadMetadata existing1 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId1", ProfilingTrigger.TRIGGER_TYPE_OOM, "path1"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing2 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId2", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "handle2", "path2"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing3 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId3", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "path3"); + + File mockFile = mock(File.class); + when(mockFile.exists()).thenReturn(false); + + when(fileStore.getCommonFile("out-of-band-uploads")).thenReturn(mockFile); + + Context context = mock(Context.class); + ApplicationInfo applicationInfo = mock(ApplicationInfo.class); + + Field field = ApplicationInfo.class.getDeclaredField("processName"); + field.setAccessible(true); + field.set(applicationInfo, "NotTheMainProcessProcess"); + + when(context.getApplicationInfo()).thenReturn(applicationInfo); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + assertThat(store.addInProgressUpload(existing1.sessionId, existing1.type, existing1.path)) + .isFalse(); + assertThat(store.addInProgressUpload(existing2.sessionId, existing2.type, existing2.path)) + .isFalse(); + assertThat(store.addInProgressUpload(existing3.sessionId, existing3.type, existing3.path)) + .isFalse(); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testRemoveInProgressUpload_uploadExists() throws IOException { + ResumableUploadMetadataStore.InProgressUploadMetadata existing1 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId1", ProfilingTrigger.TRIGGER_TYPE_OOM, "path1"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing2 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId2", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "handle2", "path2"); + ResumableUploadMetadataStore.InProgressUploadMetadata existing3 = + new ResumableUploadMetadataStore.InProgressUploadMetadata( + "sessionId3", ProfilingTrigger.TRIGGER_TYPE_ANOMALY, "path3"); + + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + File mockFile = mock(File.class); + when(mockFile.exists()).thenReturn(false); + + when(fileStore.getCommonFile("out-of-band-uploads")) + .thenReturn(mockFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile) + .thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + assertThat(store.addInProgressUpload(existing1.sessionId, existing1.type, existing1.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing2.sessionId, existing2.type, existing2.path)) + .isTrue(); + assertThat(store.addInProgressUpload(existing3.sessionId, existing3.type, existing3.path)) + .isTrue(); + + assertThat(store.updateHandle(existing2.path, existing2.handle)).isTrue(); + + Optional current = store.getUpload(); + + assertThat(current.isPresent()).isTrue(); + verify(fileStore, times(5)).getCommonFile("out-of-band-uploads"); + + assertThat(current.get().sessionId).isEqualTo(existing2.sessionId); + assertThat(current.get().path).isEqualTo(existing2.path); + assertThat(current.get().type).isEqualTo(existing2.type); + assertThat(current.get().handle).isEqualTo(existing2.handle); + + assertThat(store.removeInProgressUpload(existing2.path)).isTrue(); + + Optional newCurrent = store.getUpload(); + + assertThat(newCurrent.isPresent()).isTrue(); + + assertThat(newCurrent.get().sessionId).isEqualTo(existing1.sessionId); + assertThat(newCurrent.get().path).isEqualTo(existing1.path); + assertThat(newCurrent.get().type).isEqualTo(existing1.type); + assertThat(newCurrent.get().handle).isNull(); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testParseMalformedMetadataFile() throws IOException { + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + // Write malformed content + // Valid trigger types are 7 (OOM) and 8 (ANOMALY) based on ProfilingTrigger + // Let's use 1 for valid and something else for invalid. + String content = + "malformed,content\nvalidSession,7,handle,path\ninvalidType,99,handle,path\ntooFewTokens,1,handle"; + Files.write(metadataFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); + + when(fileStore.getCommonFile("out-of-band-uploads")).thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + Optional current = store.getUpload(); + assertThat(current.isPresent()).isTrue(); + assertThat(current.get().sessionId).isEqualTo("validSession"); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testEmptyMetadataFile() throws IOException { + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + when(fileStore.getCommonFile("out-of-band-uploads")).thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + Optional current = store.getUpload(); + assertThat(current.isPresent()).isFalse(); + } + + @Test + @SdkSuppress(minSdkVersion = 37) + public void testFlushFailureRecovery() throws IOException { + // Simulate write failure by making the file read-only + Path metadataFilePath = Files.createTempFile("test", ".test"); + File metadataFile = metadataFilePath.toFile(); + metadataFile.deleteOnExit(); + + // Make it read-only to cause IOException on write + assertThat(metadataFile.setWritable(false)).isTrue(); + + when(fileStore.getCommonFile("out-of-band-uploads")).thenReturn(metadataFile); + + ResumableUploadMetadataStore store = new ResumableUploadMetadataStore(context, fileStore); + + // Try to add upload, should fail and return false + boolean result = store.addInProgressUpload("sessionId", 1, "path"); + assertThat(result).isFalse(); + + // Reset permissions so it can be deleted + assertThat(metadataFile.setWritable(true)).isTrue(); + } +} diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index 11dd633063a..505ad4bed84 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -34,6 +34,7 @@ import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.TaskCompletionSource; import com.google.android.gms.tasks.Tasks; +import com.google.firebase.FirebaseApp; import com.google.firebase.annotations.concurrent.Background; import com.google.firebase.crashlytics.internal.CrashlyticsNativeComponent; import com.google.firebase.crashlytics.internal.Logger; @@ -46,7 +47,9 @@ import com.google.firebase.crashlytics.internal.metadata.UserMetadata; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport; import com.google.firebase.crashlytics.internal.model.StaticSessionData; +import com.google.firebase.crashlytics.internal.network.ScottyUploader; import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.settings.Settings; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; import com.google.firebase.sessions.api.CrashEventReceiver; @@ -131,6 +134,12 @@ class CrashlyticsController { // A token to make sure that checkForUnsentReports only gets called once. final AtomicBoolean checkForUnsentReportsCalled = new AtomicBoolean(false); + // Metadata management for in progress heap dump uploads. + final ResumableUploadMetadataStore resumableUploadMetadataStore; + + // Out-of-band heap dump upload client. + final ScottyUploader scottyUploader; + CrashlyticsController( Context context, IdManager idManager, @@ -144,7 +153,9 @@ class CrashlyticsController { CrashlyticsNativeComponent nativeComponent, AnalyticsEventLogger analyticsEventLogger, CrashlyticsAppQualitySessionsSubscriber sessionsSubscriber, - CrashlyticsWorkers crashlyticsWorkers) { + CrashlyticsWorkers crashlyticsWorkers, + ResumableUploadMetadataStore resumableUploadMetadataStore, + FirebaseApp app) { this.context = context; this.idManager = idManager; this.dataCollectionArbiter = dataCollectionArbiter; @@ -158,6 +169,13 @@ class CrashlyticsController { this.sessionsSubscriber = sessionsSubscriber; this.reportingCoordinator = sessionReportingCoordinator; this.crashlyticsWorkers = crashlyticsWorkers; + this.resumableUploadMetadataStore = resumableUploadMetadataStore; + this.scottyUploader = + new ScottyUploader( + app.getOptions().getApiKey(), + appData.buildId, + fileStore, + ScottyUploader.makeUploadClient(crashlyticsWorkers.network)); } // region Exception handling @@ -264,6 +282,14 @@ public Task then(@Nullable Settings settings) throws Exception { if (ex instanceof OutOfMemoryError) { Logger.getLogger().i("OOMs will be sent on restart"); + + if (android.os.Build.VERSION.SDK_INT >= VERSION_CODES.CINNAMON_BUN) { + ActivityManager activityManager = + (ActivityManager) + context.getSystemService(Context.ACTIVITY_SERVICE); + activityManager.setProcessStateSummary("OOM, Java Heap".getBytes()); + } + return Tasks.forResult(null); } // Data collection is enabled, so it's safe to send the report. @@ -535,6 +561,8 @@ private String getCurrentSessionId() { boolean finalizeSessions(SettingsProvider settingsProvider) { CrashlyticsWorkers.checkBackgroundThread(); + triggerHeapdumpUpload(); + if (isHandlingException()) { Logger.getLogger().w("Skipping session finalization because a crash has already occurred."); return Boolean.FALSE; @@ -552,6 +580,50 @@ boolean finalizeSessions(SettingsProvider settingsProvider) { return true; } + private void triggerHeapdumpUpload() { + resumableUploadMetadataStore + .getUpload() + .ifPresent( + upload -> { + String gmpAppId = appData.googleAppId; + String sessionId = upload.sessionId; + String heapdump = upload.path; + + Logger.getLogger().d("About to trigger an upload!"); + + if (upload.isInProgress()) { + scottyUploader.resumeUpload( + heapdump, + upload.handle, + new ScottyUploader.Listener() { + @Override + public void onDoneOrUnrecoverable() { + resumableUploadMetadataStore.removeInProgressUpload(heapdump); + } + }); + } + + if (upload.isNew()) { + scottyUploader.triggerUpload( + gmpAppId, + sessionId, + upload.type, + heapdump, + new ScottyUploader.Listener() { + @Override + public void onHandleAvailable(String handle) { + resumableUploadMetadataStore.updateHandle(heapdump, handle); + } + + @Override + public void onDoneOrUnrecoverable() { + resumableUploadMetadataStore.removeInProgressUpload(heapdump); + } + }); + } + }); + } + /** * Not synchronized/locked. Must be executed from the single thread executor service used by this * class. diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java index f83c78d7d02..7971658812b 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java @@ -35,6 +35,7 @@ import com.google.firebase.crashlytics.internal.metadata.LogFileManager; import com.google.firebase.crashlytics.internal.metadata.UserMetadata; import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.settings.Settings; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; import com.google.firebase.crashlytics.internal.stacktrace.MiddleOutFallbackStrategy; @@ -103,6 +104,8 @@ public class CrashlyticsCore { private final CrashlyticsWorkers crashlyticsWorkers; + private final ResumableUploadMetadataStore resumableUploadMetadataStore; + // region Constructors public CrashlyticsCore( @@ -127,6 +130,7 @@ public CrashlyticsCore( this.sessionsSubscriber = sessionsSubscriber; this.remoteConfigDeferredProxy = remoteConfigDeferredProxy; this.crashlyticsWorkers = crashlyticsWorkers; + this.resumableUploadMetadataStore = new ResumableUploadMetadataStore(context, fileStore); startTime = System.currentTimeMillis(); onDemandCounter = new OnDemandCounter(); @@ -181,7 +185,8 @@ public boolean onPreExecute(AppData appData, SettingsProvider settingsProvider) settingsProvider, onDemandCounter, sessionsSubscriber, - crashlyticsWorkers); + crashlyticsWorkers, + resumableUploadMetadataStore); controller = new CrashlyticsController( @@ -197,7 +202,9 @@ public boolean onPreExecute(AppData appData, SettingsProvider settingsProvider) nativeComponent, analyticsEventLogger, sessionsSubscriber, - crashlyticsWorkers); + crashlyticsWorkers, + resumableUploadMetadataStore, + app); // If the file is present at this point, then the previous run's initialization // did not complete, and we want to perform initialization synchronously this time. @@ -340,7 +347,7 @@ public void logException(@NonNull Throwable throwable, @NonNull MapThe log is rolling with a maximum size of 64k, such that messages are removed (oldest first) * if the max size is exceeded. * - * @see #logException(Throwable) + * @see #logException(Throwable, Map) */ public void log(final String msg) { final long timestamp = System.currentTimeMillis() - startTime; diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java index de9d202a503..57618f8131e 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java @@ -14,12 +14,13 @@ package com.google.firebase.crashlytics.internal.common; +import android.annotation.SuppressLint; import android.app.ApplicationExitInfo; import android.content.Context; import android.os.Build; import android.os.Build.VERSION_CODES; +import android.os.Build.VERSION_CODES_FULL; import android.os.ProfilingTrigger; -import android.system.OsConstants; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -37,6 +38,7 @@ import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.ProfilingManagerInfo; import com.google.firebase.crashlytics.internal.persistence.CrashlyticsReportPersistence; import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; import com.google.firebase.crashlytics.internal.stacktrace.StackTraceTrimmingStrategy; @@ -79,7 +81,8 @@ public static SessionReportingCoordinator create( SettingsProvider settingsProvider, OnDemandCounter onDemandCounter, CrashlyticsAppQualitySessionsSubscriber sessionsSubscriber, - CrashlyticsWorkers crashlyticsWorkers) { + CrashlyticsWorkers crashlyticsWorkers, + ResumableUploadMetadataStore resumableUploadMetadataStore) { final CrashlyticsReportDataCapture dataCapture = new CrashlyticsReportDataCapture( context, idManager, appData, stackTraceTrimmingStrategy, settingsProvider); @@ -94,7 +97,8 @@ public static SessionReportingCoordinator create( logFileManager, userMetadata, idManager, - crashlyticsWorkers); + crashlyticsWorkers, + resumableUploadMetadataStore); } private final CrashlyticsReportDataCapture dataCapture; @@ -106,6 +110,8 @@ public static SessionReportingCoordinator create( private final CrashlyticsWorkers crashlyticsWorkers; + private final ResumableUploadMetadataStore resumableUploadMetadataStore; + SessionReportingCoordinator( CrashlyticsReportDataCapture dataCapture, CrashlyticsReportPersistence reportPersistence, @@ -113,7 +119,8 @@ public static SessionReportingCoordinator create( LogFileManager logFileManager, UserMetadata reportMetadata, IdManager idManager, - CrashlyticsWorkers crashlyticsWorkers) { + CrashlyticsWorkers crashlyticsWorkers, + ResumableUploadMetadataStore resumableUploadMetadataStore) { this.dataCapture = dataCapture; this.reportPersistence = reportPersistence; this.reportsSender = reportsSender; @@ -121,6 +128,7 @@ public static SessionReportingCoordinator create( this.reportMetadata = reportMetadata; this.idManager = idManager; this.crashlyticsWorkers = crashlyticsWorkers; + this.resumableUploadMetadataStore = resumableUploadMetadataStore; } public void onBeginSession(@NonNull String sessionId, long timestampSeconds) { @@ -184,42 +192,43 @@ public void persistProfilingManagerInfo( List applicationExitInfoList, boolean isHeapDumpCollectionEnabled, boolean wasHeapDumpGeneratedViaMarker) { + int trigger = getTrigger(sessionId, applicationExitInfoList); + if (trigger == ProfilingTrigger.TRIGGER_TYPE_NONE) { + return; + } + + Optional heapDump = + heapDumpForSessionTime(filesDir, reportPersistence.getStartTimestampMillis(sessionId)); + + boolean wasHeapDumpGenerated = wasHeapDumpGeneratedViaMarker || heapDump.isPresent(); - Optional trigger = - isOom(sessionId, applicationExitInfoList) - ? Optional.of(ProfilingTrigger.TRIGGER_TYPE_OOM) - : isMemoryLimiterKill(sessionId, applicationExitInfoList) - ? Optional.of(ProfilingTrigger.TRIGGER_TYPE_ANOMALY) - : Optional.empty(); - - trigger.ifPresent( - t -> { - boolean wasHeapDumpGenerated = - wasHeapDumpGeneratedViaMarker - || (t == ProfilingTrigger.TRIGGER_TYPE_OOM - && wasHeapDumpGeneratedViaFile( - filesDir, reportPersistence.getStartTimestampMillis(sessionId))); - - Logger.getLogger() - .d( - "Trigger present; " - + t - + ", heap dump collection enabled? " - + (isHeapDumpCollectionEnabled ? "yes" : "no") - + ", heap dump generated? " - + (wasHeapDumpGenerated ? "yes" : "no")); - - reportPersistence.persistProfilingManagerInfo( - ProfilingManagerInfo.builder() - .setProfilingTrigger( - ProfilingManagerInfo.ProfilingTrigger.builder() - .setTrigger(t) - .setIsHeapDumpCollectionEnabled(isHeapDumpCollectionEnabled) - .setWasHeapDumpGenerated(wasHeapDumpGenerated) - .build()) - .build(), - sessionId); - }); + if (isHeapDumpCollectionEnabled && wasHeapDumpGenerated) { + heapDump + .map(File::getName) + .ifPresent( + filename -> + resumableUploadMetadataStore.addInProgressUpload(sessionId, trigger, filename)); + } + + Logger.getLogger() + .d( + "Trigger present; " + + trigger + + ", heap dump collection enabled? " + + (isHeapDumpCollectionEnabled ? "yes" : "no") + + ", heap dump generated? " + + (wasHeapDumpGenerated ? "yes" : "no")); + + reportPersistence.persistProfilingManagerInfo( + ProfilingManagerInfo.builder() + .setProfilingTrigger( + ProfilingManagerInfo.ProfilingTrigger.builder() + .setTrigger(trigger) + .setIsHeapDumpCollectionEnabled(isHeapDumpCollectionEnabled) + .setWasHeapDumpGenerated(wasHeapDumpGenerated) + .build()) + .build(), + sessionId); } public void finalizeSessionWithNativeEvent( @@ -517,6 +526,19 @@ public static String convertInputStreamToString(InputStream inputStream) throws return null; } + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + int getTrigger(String sessionId, List applicationExitInfoList) { + if (isOom(sessionId, applicationExitInfoList)) { + return ProfilingTrigger.TRIGGER_TYPE_OOM; + } + + if (isMemoryLimiterKill(sessionId, applicationExitInfoList)) { + return ProfilingTrigger.TRIGGER_TYPE_ANOMALY; + } + + return ProfilingTrigger.TRIGGER_TYPE_NONE; + } + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) @VisibleForTesting boolean isOom(String sessionId, List applicationExitInfoList) { @@ -524,20 +546,11 @@ boolean isOom(String sessionId, List applicationExitInfoLis findRelevantApplicationExitInfo( sessionId, applicationExitInfoList, - aei -> { - // Most devices should support REASON_LOW_MEMORY - boolean viaLowMemory = - aei.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY - && aei.getDescription() != null - && aei.getDescription().contains("OOM"); - // In cases where the above isn't supported, fall back to a more primitive check - boolean viaSignaled = - aei.getReason() == ApplicationExitInfo.REASON_SIGNALED - && aei.getStatus() == OsConstants.SIGKILL; - - // Get the first instance that is an OOM - return viaLowMemory || viaSignaled; - }); + aei -> + aei.getReason() == ApplicationExitInfo.REASON_CRASH + && aei.getProcessStateSummary() != null + && aei.getProcessStateSummary().length != 0 + && Arrays.equals(aei.getProcessStateSummary(), "OOM, Java Heap".getBytes())); return relevant != null; } @@ -548,10 +561,19 @@ boolean isMemoryLimiterKill(String sessionId, List applicat findRelevantApplicationExitInfo( sessionId, applicationExitInfoList, - aei -> - aei.getReason() == ApplicationExitInfo.REASON_OTHER - && aei.getDescription() != null - && aei.getDescription().contains("MemoryLimiter:AnonSwap")); + aei -> { + @SuppressLint("WrongConstant") + boolean viaMemoryLimiter = + Build.VERSION.SDK_INT_FULL >= VERSION_CODES_FULL.CINNAMON_BUN_2 + && aei.getReason() == ApplicationExitInfo.REASON_MEMORY_LIMITER; + + boolean viaOther = + aei.getReason() == ApplicationExitInfo.REASON_OTHER + && aei.getDescription() != null + && aei.getDescription().contains("MemoryLimiter:AnonSwap"); + + return viaOther || viaMemoryLimiter; + }); return relevant != null; } @@ -559,27 +581,28 @@ boolean isMemoryLimiterKill(String sessionId, List applicat @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) @VisibleForTesting static boolean wasHeapDumpGeneratedViaFile(File filesDir, long sessionStartTime) { + return heapDumpForSessionTime(filesDir, sessionStartTime).isPresent(); + } + + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + static Optional heapDumpForSessionTime(File filesDir, long sessionStartTime) { File heapDumpRoot = new File(filesDir, "profiling"); if (!heapDumpRoot.exists() || !heapDumpRoot.isDirectory()) { Logger.getLogger().d("Directory profiling/ doesn't exit"); - return false; + return Optional.empty(); } - File[] heapDumps = - heapDumpRoot.listFiles( - (dir, name) -> - name.toLowerCase().endsWith(".hprof") - || name.toLowerCase().endsWith(".perfetto-java-heap-dump")); + File[] heapDumps = heapDumpRoot.listFiles((dir, name) -> isProfilingTriggerHeapdump(name)); if (heapDumps == null || heapDumps.length == 0) { Logger.getLogger().d("No heap dumps present"); - return false; + return Optional.empty(); } return Arrays.stream(heapDumps) // There is no way to filter dumps per process (this is relevant only to multi process // Android apps) - .anyMatch( + .filter( heapDump -> { long lastModifiedTime = heapDump.lastModified(); @@ -593,6 +616,22 @@ static boolean wasHeapDumpGeneratedViaFile(File filesDir, long sessionStartTime) + heapDump.getName()); return lastModifiedTime >= sessionStartTime; - }); + }) + .findFirst(); + } + + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + private static boolean isProfilingTriggerHeapdump(String filename) { + boolean isHeapdump = + filename.toLowerCase().endsWith(".hprof") + || filename.toLowerCase().endsWith(".perfetto-java-heap-dump"); + + // On API 37.2, the filename will contain the trigger - this is used to differentiate OOM and + // MLK heap dumps from ones that were requested manually. On API 37, it is not possible. + if (android.os.Build.VERSION.SDK_INT_FULL >= VERSION_CODES_FULL.CINNAMON_BUN_2) { + return isHeapdump && filename.contains("trigger-type-"); + } + + return isHeapdump; } } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/network/ScottyUploader.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/network/ScottyUploader.java new file mode 100644 index 00000000000..c06e84f947a --- /dev/null +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/network/ScottyUploader.java @@ -0,0 +1,291 @@ +// Copyright 2026 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.crashlytics.internal.network; + +import android.annotation.SuppressLint; +import android.os.ProfilingTrigger; +import com.google.firebase.crashlytics.internal.Logger; +import com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorker; +import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender; +import com.google.uploader.client.DataStream; +import com.google.uploader.client.FileDataStream; +import com.google.uploader.client.HttpHeaders; +import com.google.uploader.client.HttpResponse; +import com.google.uploader.client.HttpUrlConnectionHttpClient; +import com.google.uploader.client.Transfer; +import com.google.uploader.client.TransferException; +import com.google.uploader.client.TransferExceptionOrHttpResponse; +import com.google.uploader.client.TransferListener; +import com.google.uploader.client.TransferOptions; +import com.google.uploader.client.UploadClient; +import com.google.uploader.client.UploadClientImpl; +import com.google.uploader.shaded.guava.util.concurrent.Futures; +import com.google.uploader.shaded.guava.util.concurrent.ListenableFuture; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Map; +import java.util.stream.Collectors; + +public class ScottyUploader { + private static final String URL_PREFIX = + DataTransportCrashlyticsReportSender.mergeStrings( + "hts/frbslgigp.ogepscmula/1frlglgc/pod", "tp:/ieaeogn-agolai.o/podv/ieo/eayula"); + + private final String key; + + private final String uuid; + private final FileStore fileStore; + + private final UploadClient uploadClient; + + private volatile ListenableFuture upload; + + public abstract static class Listener extends TransferListener { + private volatile DataStream stream; + + public Listener() {} + + FileDataStream withManagedStream(File heapdump) throws FileNotFoundException { + FileDataStream stream = new FileDataStream(heapdump); + this.stream = stream; + return stream; + } + + public void closeStream() { + if (stream == null) { + return; + } + + try { + stream.close(); + } catch (IOException e) { + Logger.getLogger().e("Failed to close DataStream", e); + } finally { + stream = null; + } + } + + @Override + public void onTransferHandleReady(Transfer transfer) { + if (transfer != null && transfer.getTransferHandle() != null) { + Logger.getLogger() + .d(String.format("Scotty handle available: %s", transfer.getTransferHandle())); + onHandleAvailable(transfer.getTransferHandle()); + } + } + + @SuppressLint("DefaultLocale") + @Override + public void onUploadProgress(Transfer transfer) { + Logger.getLogger() + .d(String.format("Still uploading heap dump; so far: %d", transfer.getBytesUploaded())); + } + + @Override + public void onResponseReceived(Transfer transfer, HttpResponse response) { + closeStream(); + if (isSuccess(response) || !isRecoverable(response)) { + Logger.getLogger() + .d(String.format("Scotty upload returned; status is: %s", response.getResponseCode())); + onDoneOrUnrecoverable(); + } + } + + @Override + public void onException(Transfer transfer, TransferException exception) { + closeStream(); + if (exception != null && !exception.isRecoverable()) { + Logger.getLogger().w("Scotty upload is in an unrecoverable state!"); + onDoneOrUnrecoverable(); + } + } + + public void onDoneOrUnrecoverable() {} + + public void onHandleAvailable(String handle) {} + + public static boolean isRecoverable(HttpResponse response) { + if (response == null) { + return false; + } + + int status = response.getResponseCode(); + return status == 504 // Gateway Timeout + || status == 503 // Service Unavailable + || status == 502 // Bad Gateway + || status == 500 // Internal Server Error + || status == 429 // Too Many Requests + || status == 425 // Too Early + || status == 408; // Request Timeout + } + + public static boolean isSuccess(HttpResponse response) { + return response != null && response.getResponseCode() == 200; + } + } + + public ScottyUploader(String key, String uuid, FileStore fileStore, UploadClient uploadClient) { + this.key = key; + this.uuid = uuid; + this.fileStore = fileStore; + this.uploadClient = uploadClient; + this.upload = Futures.immediateCancelledFuture(); + } + + public static UploadClient makeUploadClient(CrashlyticsWorker worker) { + return UploadClientImpl.newBuilder(new HttpUrlConnectionHttpClient(worker)).build(); + } + + public void triggerUpload( + String gmpAppId, String sessionId, int type, String path, Listener listener) { + if (upload.isCancelled() || upload.isDone()) { + triggerNewUpload(gmpAppId, sessionId, type, path, listener); + } + } + + public void resumeUpload(String path, String handle, Listener listener) { + if (upload.isCancelled() || upload.isDone()) { + resumeExistingUpload(path, handle, listener); + } + } + + private void triggerNewUpload( + String gmpAppId, String sessionId, int type, String filename, Listener listener) { + File heapdump = getHeapdumpFile(filename); + + if (heapdump.exists()) { + triggerNewUpload(gmpAppId, sessionId, type, heapdump, listener); + return; + } + + Logger.getLogger().w(String.format("Heapdump does not exist, skipping upload; %s", filename)); + listener.onDoneOrUnrecoverable(); + } + + private void triggerNewUpload( + String gmpAppId, String sessionId, int type, File heapdump, Listener listener) { + try { + String url = getUrl(key, uuid, gmpAppId, sessionId, type); + String canonicalFilename = makeCanonicalFilename(sessionId, type); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/octet-stream"); + headers.set("X-Goog-Upload-File-Name", canonicalFilename); + + TransferOptions options = TransferOptions.newBuilder().build(); + Transfer transfer = + uploadClient.createTransfer( + url, "POST", headers, listener.withManagedStream(heapdump), "", options); + + transfer.attachListener( + listener, /* progressThresholdBytes= */ 1024 * 1024, /* progressThresholdMillis= */ 1000); + + Logger.getLogger() + .d( + String.format( + "Triggering a heap dump upload for: %s, uploaded as: %s", + heapdump.getName(), canonicalFilename)); + + upload = transfer.send(); + } catch (Exception e) { + Logger.getLogger().e("Couldn't upload", e); + listener.closeStream(); + listener.onDoneOrUnrecoverable(); + } + } + + private void resumeExistingUpload(String filename, String handle, Listener listener) { + File heapdump = getHeapdumpFile(filename); + + if (heapdump.exists()) { + resumeExistingUpload(heapdump, handle, listener); + return; + } + + Logger.getLogger() + .w(String.format("Heapdump no longer exists, skipping resuming upload; %s", filename)); + listener.onDoneOrUnrecoverable(); + } + + private void resumeExistingUpload(File heapdump, String handle, Listener listener) { + try { + TransferOptions options = TransferOptions.newBuilder().build(); + Transfer transfer = + uploadClient.resumeTransfer(handle, listener.withManagedStream(heapdump), options); + + transfer.attachListener( + listener, /* progressThresholdBytes= */ 1024 * 1024, /* progressThresholdMillis= */ 500); + + upload = transfer.send(); + } catch (Exception e) { + Logger.getLogger().e("Couldn't upload", e); + listener.closeStream(); + listener.onDoneOrUnrecoverable(); + } + } + + private File getHeapdumpFile(String filename) { + // Heapdumps are stored in the app's files directory under "profiling/" + // See SessionReportingCoordinator#heapDumpForSessionTime + File current = fileStore.getCommonFile(""); + while (current != null) { + File profilingDir = new File(current, "profiling"); + if (profilingDir.exists() && profilingDir.isDirectory()) { + return new File(profilingDir, filename); + } + current = current.getParentFile(); + } + + Logger.getLogger().w("Could not find profiling directory, falling back to relative path"); + return fileStore.getCommonFile(String.format("../../profiling/%s", filename)); + } + + private String makeCanonicalFilename(String sessionId, int type) { + String trigger = type == ProfilingTrigger.TRIGGER_TYPE_OOM ? "oom" : "mlk"; + return String.format("%s/%s.perfetto", trigger, sessionId); + } + + private static String getUrl( + String key, String uuid, String gmpAppId, String sessionId, int type) { + Map params = + Map.of( + "uploadType", "media", + "gmp_app_id", gmpAppId, + "uuid", uuid, + "trigger_type", triggerAsString(type), + "session_id", sessionId, + "key", key); + + return String.format( + "%s?%s", + URL_PREFIX, + params.entrySet().stream() + .map(entry -> String.format("%s=%s", entry.getKey(), entry.getValue())) + .collect(Collectors.joining("&"))); + } + + private static String triggerAsString(int type) { + switch (type) { + case ProfilingTrigger.TRIGGER_TYPE_ANOMALY: + return "mlk"; + case ProfilingTrigger.TRIGGER_TYPE_OOM: + return "oom"; + default: + return "none"; + } + } +} diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/ResumableUploadMetadataStore.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/ResumableUploadMetadataStore.java new file mode 100644 index 00000000000..e3160a6bde8 --- /dev/null +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/ResumableUploadMetadataStore.java @@ -0,0 +1,289 @@ +// Copyright 2026 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.crashlytics.internal.persistence; + +import android.app.Application; +import android.content.Context; +import android.os.Build; +import android.os.ProfilingTrigger; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import com.google.firebase.crashlytics.internal.Logger; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ResumableUploadMetadataStore { + private static final String METADATA_FILENAME = "out-of-band-uploads"; + + private final Context context; + + private final FileStore fileStore; + + private final List uploads; + + public static class InProgressUploadMetadata { + + @NonNull public final String sessionId; + public final int type; + + @Nullable public String handle; + + @NonNull public final String path; + + public InProgressUploadMetadata( + @NonNull String sessionId, int type, @NonNull String handle, @NonNull String path) { + this.sessionId = sessionId; + this.type = type; + this.handle = handle; + this.path = path; + } + + public InProgressUploadMetadata(@NonNull String sessionId, int type, @NonNull String path) { + this.sessionId = sessionId; + this.type = type; + this.handle = null; + this.path = path; + } + + public void setHandle(@Nullable String handle) { + this.handle = handle; + } + + public boolean isInProgress() { + return handle != null; + } + + public boolean isNew() { + return handle == null; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof InProgressUploadMetadata)) return false; + InProgressUploadMetadata metadata = (InProgressUploadMetadata) o; + return type == metadata.type + && Objects.equals(sessionId, metadata.sessionId) + && Objects.equals(handle, metadata.handle) + && Objects.equals(path, metadata.path); + } + + @Override + public int hashCode() { + return Objects.hash(sessionId, type, handle, path); + } + } + + public ResumableUploadMetadataStore(Context context, FileStore fileStore) { + this.context = context; + this.fileStore = fileStore; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + this.uploads = new CopyOnWriteArrayList<>(getInProgressUploads(fileStore)); + } else { + this.uploads = new CopyOnWriteArrayList<>(); + } + } + + public Optional getUpload() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && isDefaultProcess(context)) { + // Find the oldest in-progress upload. An in progress upload will contain a non-null handle + return uploads.reversed().stream() + .filter(InProgressUploadMetadata::isInProgress) + .findFirst() + // If no uploads have a valid handle, get the oldest upload + .or(() -> uploads.isEmpty() ? Optional.empty() : Optional.of(uploads.getLast())); + } + return Optional.empty(); + } + + public boolean addInProgressUpload(@NonNull String sessionId, int type, @NonNull String path) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && isDefaultProcess(context)) { + if (uploads.stream().noneMatch(upload -> upload.path.equals(path))) { + InProgressUploadMetadata newUpload = new InProgressUploadMetadata(sessionId, type, path); + uploads.add( + 0, newUpload); // Add to front (oldest first logic in getUpload expects this order) + + if (!flush(fileStore, uploads)) { + Logger.getLogger().e("Unable to add a new in progress upload and flush"); + uploads.remove(newUpload); + return false; + } + + return true; + } + } + + return false; + } + + public boolean updateHandle(@NonNull String path, @NonNull String handle) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + return uploads.stream() + .filter(upload -> upload.path.equals(path)) + .findFirst() + .map( + upload -> { + upload.setHandle(handle); + + if (!flush(fileStore, uploads)) { + Logger.getLogger().e("Unable to update handle and flush an in progress upload"); + upload.setHandle(null); + return false; + } + + return true; + }) + .orElse(false); + } + + return false; + } + + public boolean removeInProgressUpload(@NonNull String path) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + if (uploads.removeIf(upload -> upload.path.equals(path))) { + if (!flush(fileStore, uploads)) { + Logger.getLogger().e("Unable to remove and flush an in progress upload"); + return false; + } + + return true; + } + } + + return false; + } + + @RequiresApi(api = 37) + private static List getInProgressUploads(FileStore fileStore) { + File metadata = fileStore.getCommonFile(METADATA_FILENAME); + + return metadata.exists() ? parseInProgressUploadMetadata(metadata) : new ArrayList<>(); + } + + @SuppressWarnings({"BooleanMethodIsAlwaysInverted"}) + @RequiresApi(api = 37) + private static boolean flush(FileStore fileStore, List uploads) { + File metadata = fileStore.getCommonFile(METADATA_FILENAME); + + if (!metadata.exists() && !touch(fileStore)) { + return false; + } + + try { + String content = serialize(uploads); + Files.write(metadata.toPath(), content.getBytes()); + return true; + } catch (IOException e) { + Logger.getLogger().e("Unable to flush the in progress upload metadata file!"); + } + + return false; + } + + @RequiresApi(api = 37) + private static boolean touch(FileStore fileStore) { + try { + File metadata = fileStore.getCommonFile(METADATA_FILENAME); + return metadata.createNewFile(); + } catch (IOException e) { + Logger.getLogger().e("Unable to create the in progress upload metadata file!"); + } + + return false; + } + + @RequiresApi(api = 37) + private static String serialize(List uploads) { + return uploads.stream() + .map(ResumableUploadMetadataStore::serialize) + .collect(Collectors.joining("\n")); + } + + @RequiresApi(api = 37) + @VisibleForTesting + static String serialize(InProgressUploadMetadata upload) { + return String.format("%s,%s,%s,%s", upload.sessionId, upload.type, upload.handle, upload.path); + } + + @RequiresApi(api = 37) + private static List parseInProgressUploadMetadata(File metadataFile) { + try (Stream stream = Files.lines(metadataFile.toPath())) { + return stream + .map(ResumableUploadMetadataStore::parseInProgressUploadLine) + .flatMap(Optional::stream) + .collect(Collectors.toList()); + } catch (IOException e) { + Logger.getLogger().e("Unable to read in-progress upload file"); + } + + return new ArrayList<>(); + } + + @RequiresApi(api = 37) + @VisibleForTesting + static Optional parseInProgressUploadLine(String line) { + // The format of each line is ,,, + String[] tokenized = line.split(","); + + if (tokenized.length < 4 + || !Set.of( + String.valueOf(ProfilingTrigger.TRIGGER_TYPE_ANOMALY), + String.valueOf(ProfilingTrigger.TRIGGER_TYPE_OOM)) + .contains(tokenized[1])) { + return Optional.empty(); + } + + try { + String sessionId = tokenized[0]; + + int type = Integer.parseInt(tokenized[1]); + + String handle = tokenized[2]; + String path = Arrays.stream(tokenized).skip(3).collect(Collectors.joining(",")); + + return Optional.of( + handle.equals("null") + ? new InProgressUploadMetadata(sessionId, type, path) + : new InProgressUploadMetadata(sessionId, type, handle, path)); + } catch (NumberFormatException e) { + Logger.getLogger().e("Malformed line; ", e); + } + + return Optional.empty(); + } + + @RequiresApi(api = Build.VERSION_CODES.CINNAMON_BUN) + private boolean isDefaultProcess(Context context) { + String currentProcess = Application.getProcessName(); + + String defaultProcess = context.getApplicationInfo().processName; + return defaultProcess != null + ? currentProcess.equals(defaultProcess) + : currentProcess.equals(context.getPackageName()); + } +} diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/send/DataTransportCrashlyticsReportSender.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/send/DataTransportCrashlyticsReportSender.java index 7637f7218d8..602690ab9f3 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/send/DataTransportCrashlyticsReportSender.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/send/DataTransportCrashlyticsReportSender.java @@ -76,7 +76,7 @@ public Task enqueueReport( return reportQueue.enqueueReport(reportWithSessionId, isOnDemand).getTask(); } - private static String mergeStrings(String part1, String part2) { + public static String mergeStrings(String part1, String part2) { int sizeDiff = part1.length() - part2.length(); if (sizeDiff < 0 || sizeDiff > 1) { throw new IllegalArgumentException("Invalid input received"); diff --git a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java index 469c920f13c..eb85429738b 100644 --- a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java +++ b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java @@ -28,6 +28,8 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; import com.google.firebase.concurrent.TestOnlyExecutors; import com.google.firebase.crashlytics.internal.CrashlyticsNativeComponent; import com.google.firebase.crashlytics.internal.CrashlyticsNativeComponentDeferredProxy; @@ -37,6 +39,7 @@ import com.google.firebase.crashlytics.internal.metadata.LogFileManager; import com.google.firebase.crashlytics.internal.metadata.UserMetadata; import com.google.firebase.crashlytics.internal.persistence.FileStore; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.settings.Settings; import com.google.firebase.crashlytics.internal.settings.Settings.FeatureFlagData; import com.google.firebase.crashlytics.internal.settings.Settings.SessionData; @@ -67,6 +70,8 @@ public class CrashlyticsControllerRobolectricTest { @Mock private SessionReportingCoordinator mockSessionReportingCoordinator; @Mock private DataCollectionArbiter mockDataCollectionArbiter; + @Mock private FirebaseApp firebaseApp; + private CrashlyticsWorkers crashlyticsWorkers; private static final CrashlyticsNativeComponent MISSING_NATIVE_COMPONENT = @@ -86,6 +91,15 @@ public void setUp() { testFileStore = new FileStore(testContext); crashlyticsWorkers = new CrashlyticsWorkers(TestOnlyExecutors.background(), TestOnlyExecutors.blocking()); + + FirebaseOptions options = + new FirebaseOptions.Builder() + .setApiKey("api-key") + .setApplicationId("applicationId") + .setProjectId("projectId") + .build(); + + when(firebaseApp.getOptions()).thenReturn(options); } @Test @@ -179,6 +193,9 @@ private CrashlyticsController createController() { "versionName", mock(DevelopmentPlatformProvider.class)); + ResumableUploadMetadataStore store = + new ResumableUploadMetadataStore(testContext, testFileStore); + final CrashlyticsController controller = new CrashlyticsController( testContext, @@ -193,7 +210,9 @@ private CrashlyticsController createController() { MISSING_NATIVE_COMPONENT, mock(AnalyticsEventLogger.class), mock(CrashlyticsAppQualitySessionsSubscriber.class), - crashlyticsWorkers); + crashlyticsWorkers, + store, + firebaseApp); controller.openSession(SESSION_ID); return controller; } diff --git a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java index f63665825b8..6d532d843a1 100644 --- a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java +++ b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java @@ -34,6 +34,7 @@ import com.google.firebase.crashlytics.internal.metadata.UserMetadata; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport; import com.google.firebase.crashlytics.internal.persistence.CrashlyticsReportPersistence; +import com.google.firebase.crashlytics.internal.persistence.ResumableUploadMetadataStore; import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -62,6 +63,8 @@ public class SessionReportingCoordinatorRobolectricTest { @Mock private LogFileManager mockLogFileManager; @Mock UserMetadata mockUserMetadata; + @Mock private ResumableUploadMetadataStore store; + private SessionReportingCoordinator reportingCoordinator; private final CrashlyticsWorkers crashlyticsWorkers = @@ -79,7 +82,8 @@ public void setUp() { logFileManager, reportMetadata, idManager, - crashlyticsWorkers); + crashlyticsWorkers, + store); mockEventInteractions(); } diff --git a/firebase-sessions/test-app/src/main/AndroidManifest.xml b/firebase-sessions/test-app/src/main/AndroidManifest.xml index 8d2011ca18e..c42785262cc 100644 --- a/firebase-sessions/test-app/src/main/AndroidManifest.xml +++ b/firebase-sessions/test-app/src/main/AndroidManifest.xml @@ -28,7 +28,6 @@ android:exported="true" android:label="@string/app_name" android:name=".MainActivity" - android:process=":main" android:theme="@style/Theme.Widget_test_app.NoActionBar"> diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt index aea8f13c77c..1bf10a747b1 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt @@ -65,6 +65,42 @@ class FirstFragment : Fragment() { return binding.root } + fun doStuff(pinnedMemory: MutableList, stringMemory: MutableList) { + doStuff(pinnedMemory, stringMemory, 0) + } + + fun doStuff(pinnedMemory: MutableList, stringMemory: MutableList, i: Int) { + thread(name = "lmk-filler-thread_$i") { + val runtime = Runtime.getRuntime() + val maxHeap = runtime.maxMemory() + + Logger.getLogger().i("Max Java Heap: ${maxHeap / (1024 * 1024)} MB") + + val usedHeap = runtime.totalMemory() - runtime.freeMemory() + val usage = 1 + + if (usedHeap >= maxHeap * usage) { + return@thread + } + + val chunk = ByteArray(2 * 1024 * 1024) + + for (i in chunk.indices step 4096) { + chunk[i] = (i and 0xFF).toByte() + } + + val builder = StringBuilder() + for (b in chunk) { + builder.append(String.format("%02x", b)) + } + + pinnedMemory.add(chunk) + stringMemory.add(String.format("Byte is: %s", builder.toString())) + + doStuff(pinnedMemory, stringMemory, i + 1) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -78,32 +114,11 @@ class FirstFragment : Fragment() { } } val pinnedMemory = mutableListOf() + val stringMemory = mutableListOf() binding.buttonOom.setOnClickListener { Logger.getLogger().i("OOM clicked") - thread(name = "lmk-filler-thread") { - val runtime = Runtime.getRuntime() - val maxHeap = runtime.maxMemory() - - Logger.getLogger().i("Max Java Heap: ${maxHeap / (1024 * 1024)} MB") - - while (true) { - val usedHeap = runtime.totalMemory() - runtime.freeMemory() - val usage = 1 - - if (usedHeap >= maxHeap * usage) { - break - } - - val chunk = ByteArray(2 * 1024 * 1024) - - for (i in chunk.indices step 4096) { - chunk[i] = (i and 0xFF).toByte() - } - - pinnedMemory.add(chunk) - } - } + doStuff(pinnedMemory, stringMemory) } binding.buttonMlk.setOnClickListener { thread {