Handling Notification Deep Links with Cold Start Resilience in Android
Handling Notification Deep Links with Cold Start Resilience in Android
When implementing push notification handlers in Android, a common challenge involves routing users to specific content screens regardless of whether the application process is currently active. The complexity arises from initialization dependencies: target atcivities often require application-level setup (user authentication, configuration loading, database initialization) that only occurs during normal app startup.
Consider an e-commerce scenario where a promotional notification must open a product page. If the user taps while the app runs, navigation should proceed immediately. However, if the process terminated, the app must complete its bootstrap sequence before displaying the product, ensuring that checkout functionality dependent on session data functions correctly.
Architectural Approach
The solution requires distinguishing between two process states and handling navigation accordingly:
Active Process State: The application process exists in memory, though the activity stack might be empty (user pressed back to exit). Directly launching the target activity creates an orphaned task stack where the back button exits to the home screen rather than the main interface.
Incative Process State: No application process exists. Launching requires starting from the entry point (Splash/Launch activity), initializing dependencies, then navigating to the destination.
The implementation uses a routing broadcast receiver to intercept notification taps, detect process viability, and construct appropriate intent sequences.
Component Structure
The implementation comprises these components executing across separate processes:
- MessageService: Background service monitoring push channels in an isolated process (
:remote) - NotificationPublisher: Broadcast receiver constructing the notification UI
- NavigationRouter: Broadcast receiver handling tap gestures and route determination
- LaunchActivity: Entry point managing initialization and splash display
- HomeActivity: Primary interface serving as the navigation hub
- ProductActivity: Destination displaying item details
Service Implementation
Configure the remote service in AndroidManifest.xml:
<service
android:name=".MessageService"
android:process=":remote" />
The service schedules periodic notification simulations using AlarmManager:
public class MessageService extends Service {
private static final String TAG = "RemoteService";
@Override
public void onCreate() {
super.onCreate();
AlarmManager scheduler = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
Intent trigger = new Intent(this, NotificationPublisher.class);
PendingIntent operation = PendingIntent.getBroadcast(
this,
0,
trigger,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
scheduler.setExact(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + 5000,
operation
);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
Notification Construction
The publisher builds the notification with a routing pending intent:
public class NotificationPublisher extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Intent routeAction = new Intent(context, NavigationRouter.class);
PendingIntent tapAction = PendingIntent.getBroadcast(
context,
(int) System.currentTimeMillis(),
routeAction,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "promotions")
.setContentTitle("Special Offer")
.setContentText("Check out today's deals")
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.setContentIntent(tapAction);
NotificationManager manager = (NotificationManager)
context.getSystemService(Context.NOTIFICATION_SERVICE);
manager.notify(1, builder.build());
}
}
Route Resolution Logic
The router determines process state and constructs the appropriate launch sequence:
public class NavigationRouter extends BroadcastReceiver {
private static final String TARGET_PACKAGE = "com.example.notificationrouter";
private static final String EXTRA_PAYLOAD = "navigation_payload";
@Override
public void onReceive(Context context, Intent intent) {
boolean isRunning = ProcessChecker.isApplicationRunning(context, TARGET_PACKAGE);
if (isRunning) {
// Process exists: Ensure MainActivity exists in stack before ProductActivity
Intent mainIntent = new Intent(context, HomeActivity.class);
mainIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Intent productIntent = new Intent(context, ProductActivity.class);
productIntent.putExtra("product_id", "SKU-8842");
productIntent.putExtra("product_name", "Wireless Headphones");
productIntent.putExtra("discount", "30%");
// Launch array preserves back-stack navigation
context.startActivities(new Intent[]{mainIntent, productIntent});
} else {
// Process dead: Launch via entry point with payload
Intent launcher = context.getPackageManager()
.getLaunchIntentForPackage(TARGET_PACKAGE);
launcher.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED |
Intent.FLAG_ACTIVITY_CLEAR_TOP
);
Bundle payload = new Bundle();
payload.putString("product_id", "SKU-8842");
payload.putString("product_name", "Wireless Headphones");
payload.putString("discount", "30%");
payload.putBoolean("from_notification", true);
launcher.putExtra(EXTRA_PAYLOAD, payload);
context.startActivity(launcher);
}
}
}
Process State Detection
Utility class for determining application viability:
public class ProcessChecker {
public static boolean isApplicationRunning(Context context, String packageName) {
ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
List<RunningAppProcessInfo> processes = manager.getRunningAppProcesses();
if (processes == null) return false;
for (RunningAppProcessInfo process : processes) {
if (process.processName.equals(packageName) &&
process.importance != RunningAppProcessInfo.IMPORTANCE_EMPTY) {
return true;
}
}
return false;
}
}
Activity Chain Handling
LaunchActivity manages the cold start parameter propagation:
public class LaunchActivity extends AppCompatActivity {
private static final String EXTRA_PAYLOAD = "navigation_payload";
private Handler uiHandler = new Handler(Looper.getMainLooper());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_launch);
// Simulate initialization (3 seconds)
uiHandler.postDelayed(() -> {
Intent nextStep = new Intent(this, HomeActivity.class);
// Forward notification payload if present
Bundle notificationData = getIntent().getBundleExtra(EXTRA_PAYLOAD);
if (notificationData != null) {
nextStep.putExtra(EXTRA_PAYLOAD, notificationData);
}
startActivity(nextStep);
finish();
}, 3000);
}
}
HomeActivity checks for pending navigation requests after initialization:
public class HomeActivity extends AppCompatActivity {
private static final String EXTRA_PAYLOAD = "navigation_payload";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_home);
// Check for deep link request from notification
Bundle routeData = getIntent().getBundleExtra(EXTRA_PAYLOAD);
if (routeData != null && routeData.getBoolean("from_notification", false)) {
navigateToProduct(routeData);
}
}
private void navigateToProduct(Bundle data) {
Intent detailView = new Intent(this, ProductActivity.class);
detailView.putExtras(data);
detailView.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(detailView);
}
}
ProductActivity displays the final destination:
public class ProductActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_product);
String productName = getIntent().getStringExtra("product_name");
String discount = getIntent().getStringExtra("discount");
String sku = getIntent().getStringExtra("product_id");
TextView titleView = findViewById(R.id.product_title);
TextView discountView = findViewById(R.id.discount_badge);
titleView.setText(productName);
discountView.setText(discount);
}
}
Key Considerations
When implementing this pattern, ensure that HomeActivity uses launchMode="singleTask" or handle intent flags appropriately to prevent duplicate instances in the stack. The intent array approach for active processes ensures proper back navigation, while the bundle chaining through LaunchActivity guarantees initialization completion during cold starts.
For production environments, consider using WorkManager for reliable notification scheduling instead of AlarmManager, and implement proper notification channels for Android O and above.