Skip to content

Commit ac3defe

Browse files
markushiclaude
andcommitted
fix(android): Improve warm start detection with API 35+ support
- Add support for ApplicationStartInfo API on Android 15+ (API 35) for more accurate cold/warm start detection - Add null safety check for ActivityManager service - Fix detection logic to prevent legacy methods from running when API 35+ already determined start type - Set app start type to WARM when app moves to background (process stays alive) - Add comprehensive test coverage with notification-triggered start scenarios Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 15eeb49 commit ac3defe

File tree

5 files changed

+158
-10
lines changed

5 files changed

+158
-10
lines changed

sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package io.sentry.android.core.performance;
22

33
import android.app.Activity;
4+
import android.app.ActivityManager;
45
import android.app.Application;
6+
import android.app.ApplicationStartInfo;
57
import android.content.ContentProvider;
8+
import android.content.Context;
69
import android.os.Build;
710
import android.os.Bundle;
811
import android.os.Handler;
@@ -335,7 +338,25 @@ public void registerLifecycleCallbacks(final @NotNull Application application) {
335338
appLaunchedInForeground.resetValue();
336339
application.registerActivityLifecycleCallbacks(instance);
337340

338-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
341+
final @Nullable ActivityManager activityManager =
342+
(ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE);
343+
344+
if (activityManager != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
345+
final List<ApplicationStartInfo> historicalProcessStartReasons =
346+
activityManager.getHistoricalProcessStartReasons(1);
347+
if (!historicalProcessStartReasons.isEmpty()) {
348+
final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0);
349+
if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) {
350+
if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) {
351+
appStartType = AppStartType.COLD;
352+
} else {
353+
appStartType = AppStartType.WARM;
354+
}
355+
}
356+
}
357+
}
358+
359+
if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
339360
Looper.getMainLooper()
340361
.getQueue()
341362
.addIdleHandler(
@@ -347,7 +368,7 @@ public boolean queueIdle() {
347368
return false;
348369
}
349370
});
350-
} else {
371+
} else if (appStartType == AppStartType.UNKNOWN) {
351372
// We post on the main thread a task to post a check on the main thread. On Pixel devices
352373
// (possibly others) the first task posted on the main thread is called before the
353374
// Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate
@@ -402,12 +423,15 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved
402423
CLASS_LOADED_UPTIME_MS = activityCreatedUptimeMillis;
403424
contentProviderOnCreates.clear();
404425
applicationOnCreate.reset();
405-
} else if (savedInstanceState != null) {
406-
appStartType = AppStartType.WARM;
407-
} else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) {
408-
appStartType = AppStartType.WARM;
409-
} else {
410-
appStartType = AppStartType.COLD;
426+
} else if (appStartType == AppStartType.UNKNOWN) {
427+
// pre API 35 handling
428+
if (savedInstanceState != null) {
429+
appStartType = AppStartType.WARM;
430+
} else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) {
431+
appStartType = AppStartType.WARM;
432+
} else {
433+
appStartType = AppStartType.COLD;
434+
}
411435
}
412436
}
413437
appLaunchedInForeground.setValue(true);
@@ -451,6 +475,7 @@ public void onActivityDestroyed(@NonNull Activity activity) {
451475
// if the app is moving into background
452476
// as the next onActivityCreated will treat it as a new warm app start
453477
if (remainingActivities == 0 && !activity.isChangingConfigurations()) {
478+
appStartType = AppStartType.WARM;
454479
appLaunchedInForeground.setValue(true);
455480
shouldSendStartMeasurements = true;
456481
firstDrawDone.set(false);

sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,38 @@ name: App Launch Tests
1616
stopApp: false
1717
- assertVisible: "App Start Type: WARM"
1818

19-
# Test 3: Launch app after a broadcast receiver already created the application
19+
# Test 3: Notification (WARM start)
20+
- launchApp:
21+
stopApp: true
22+
permissions:
23+
all: allow
24+
- assertVisible: "Welcome!"
25+
- tapOn: "Trigger Notification"
26+
- tapOn: "Finish Activity"
27+
- assertNotVisible: "Welcome!"
28+
- swipe:
29+
start: 50%, 0%
30+
end: 50%, 50%
31+
- tapOn: "Sentry Test Notification"
32+
- assertVisible: "App Start Type: WARM"
33+
34+
35+
# Test 4: Notification (COLD start)
36+
- launchApp:
37+
stopApp: true
38+
permissions:
39+
all: allow
40+
- assertVisible: "Welcome!"
41+
- tapOn: "Trigger Notification"
42+
- killApp
43+
- swipe:
44+
start: 50%, 0%
45+
end: 50%, 50%
46+
- tapOn: "Sentry Test Notification"
47+
- assertVisible: "App Start Type: COLD"
48+
49+
50+
# Test 5: Launch app after a broadcast receiver already created the application
2051
# Uncomment once https://github.com/mobile-dev-inc/Maestro/pull/2925 is merged
2152
# - killApp
2253
# - sendBroadcast:

sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools">
44

5+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
6+
57
<application
68
android:name=".App"
79
android:label="Sentry UI Tests Critical"

sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package io.sentry.uitest.android.critical
22

33
import android.content.Intent
4+
import android.os.Build
45
import android.os.Bundle
6+
import android.widget.Toast
57
import androidx.activity.ComponentActivity
68
import androidx.activity.compose.setContent
9+
import androidx.activity.result.ActivityResultLauncher
10+
import androidx.activity.result.contract.ActivityResultContracts
711
import androidx.compose.foundation.layout.Column
812
import androidx.compose.foundation.layout.fillMaxSize
913
import androidx.compose.foundation.layout.padding
@@ -20,15 +24,30 @@ import androidx.compose.ui.Modifier
2024
import androidx.compose.ui.unit.dp
2125
import io.sentry.Sentry
2226
import io.sentry.android.core.performance.AppStartMetrics
27+
import io.sentry.uitest.android.critical.NotificationHelper.showNotification
2328
import java.io.File
2429
import kotlinx.coroutines.delay
2530

2631
class MainActivity : ComponentActivity() {
32+
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String?>
33+
2734
override fun onCreate(savedInstanceState: Bundle?) {
35+
setTheme(android.R.style.Theme_DeviceDefault_NoActionBar)
36+
2837
super.onCreate(savedInstanceState)
2938
val outboxPath =
3039
Sentry.getCurrentHub().options.outboxPath ?: throw RuntimeException("Outbox path is not set.")
3140

41+
requestPermissionLauncher =
42+
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
43+
if (isGranted) {
44+
// Permission granted, show notification
45+
postNotification()
46+
} else {
47+
// Permission denied, handle accordingly
48+
Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show()
49+
}
50+
}
3251
setContent {
3352
var appStartType by remember { mutableStateOf("") }
3453

@@ -39,7 +58,7 @@ class MainActivity : ComponentActivity() {
3958

4059
MaterialTheme {
4160
Surface {
42-
Column(modifier = Modifier.fillMaxSize().padding(20.dp)) {
61+
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
4362
Text(text = "Welcome!")
4463
Text(text = "App Start Type: $appStartType")
4564

@@ -61,6 +80,17 @@ class MainActivity : ComponentActivity() {
6180
Text("Write Corrupted Envelope")
6281
}
6382
Button(onClick = { finish() }) { Text("Finish Activity") }
83+
Button(
84+
onClick = {
85+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
86+
requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
87+
} else {
88+
postNotification()
89+
}
90+
}
91+
) {
92+
Text("Trigger Notification")
93+
}
6494
Button(
6595
onClick = {
6696
startActivity(
@@ -81,4 +111,12 @@ class MainActivity : ComponentActivity() {
81111
}
82112
}
83113
}
114+
115+
fun postNotification() {
116+
NotificationHelper.showNotification(
117+
this@MainActivity,
118+
"Sentry Test Notification",
119+
"This is a test notification.",
120+
)
121+
}
84122
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.sentry.uitest.android.critical
2+
3+
import android.R
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.PendingIntent
7+
import android.content.Context
8+
import android.content.Intent
9+
import android.os.Build
10+
import androidx.core.app.NotificationCompat
11+
12+
object NotificationHelper {
13+
14+
private const val CHANNEL_ID = "channel_id"
15+
private const val NOTIFICATION_ID = 1
16+
17+
fun showNotification(context: Context, title: String?, message: String?) {
18+
val notificationManager =
19+
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
20+
21+
// Create notification channel for Android 8.0+ (API 26+)
22+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
23+
val channel =
24+
NotificationChannel(CHANNEL_ID, "Notifications", NotificationManager.IMPORTANCE_DEFAULT)
25+
channel.description = "description"
26+
notificationManager.createNotificationChannel(channel)
27+
}
28+
29+
// Intent to open when notification is tapped
30+
val intent = Intent(context, MainActivity::class.java)
31+
val pendingIntent =
32+
PendingIntent.getActivity(
33+
context,
34+
0,
35+
intent,
36+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
37+
)
38+
39+
// Build the notification
40+
val builder =
41+
NotificationCompat.Builder(context, CHANNEL_ID)
42+
.setSmallIcon(R.drawable.ic_dialog_info)
43+
.setContentTitle(title)
44+
.setContentText(message)
45+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
46+
.setContentIntent(pendingIntent)
47+
.setAutoCancel(true) // Dismiss when tapped
48+
49+
// Show the notification
50+
notificationManager.notify(NOTIFICATION_ID, builder.build())
51+
}
52+
}

0 commit comments

Comments
 (0)