Skip to content

Conversation

@rahul-lohra
Copy link
Contributor

@rahul-lohra rahul-lohra commented Dec 23, 2025

Goal

  1. Fix FG Service Notification dismiss from Notification Tray
  2. Fix Ringing State update process
  3. Reuse same FG Service when transitioning from Ringing to Active Call instead of killing & respawn FG Service
  4. Split Call Service into smaller classes

Implementation

Core Changes

Core classes

  1. ClientState
  2. CallState
Class Change description
ClientState Instead of stopping restarting service we are now transitioning incoming/outgoing call to active on the same service
CallState - Prevent race-condition in event consumption & processing in Service Class by firing new local events when their original events are consumed
-Since we will use same service to transition from ringing to active so we need to store a reference of original notification Id
- Process LocalCallMissedEvent here so that it will work for calls which are not active

Service classes refactoring

Class Change description
ServiceIntentBuilder Creates and configures the various Intent objects used to communicate with the CallService.
ServiceLauncher Provides a clean and centralized API to start, stop, and interact with the CallService.
CallServiceLifecycleManager Manages the core business logic of starting, ending, and handling the lifecycle of a call.
CallServiceNotificationManager Responsible for creating, displaying, and updating all notifications shown by the service.
ServiceState The single source of truth that holds the current state of the service, such as active and ringing call IDs.
CallServiceEventObserver Listens to VideoEvent changes from the Call and updates the ServiceState accordingly.
CallServiceNotificationUpdateObserver Observes state changes and triggers updates to the foreground notification.
CallServiceRingingStateObserver Manages the ringing state and handles transitions between incoming, active, and idle calls.

Util Classes

Class Change description
Debouncer To debounce the stopping of FG Service to ensure enough time has passed for the system to process the notification removal.
Throttler Throttling the service by [CallService.SERVICE_DESTROY_THROTTLE_TIME_MS] such that the stop service is invoked once (at least less frequently)

Testing

Fix FG Service Notification dismiss from Notification Tray

  1. Open the notification drawer in callee phone
  2. Make a call and end the call within 1-2 second.
  3. The notification should properly update on the callee's phone

Service lifecycle

  1. When a call is ended, service should get destroyed (check from the logs)

Summary by CodeRabbit

  • New Features

    • Added call state post-events for enhanced event tracking when calls are accepted or rejected.
    • Enhanced missed call event handling to include creator information.
  • Bug Fixes

    • Improved call state transitions and acceptance handling to ensure consistent behavior across devices.
    • Refined foreground service management with better timing and coordination.
    • Enhanced notification lifecycle tracking and management.
    • Improved handling of multiple simultaneous calls with robust service state management.

✏️ Tip: You can customize this high-level summary in your review settings.

Adds more detailed logging to the `CallEventObserver` to provide better insight into the handling of incoming call events.
When a `CallRejectedEvent` is received, the `createdBy` field on the call state is now updated with the user who created the call.
Introduces `LocalCallAcceptedEvent` and `LocalCallRejectedEvent` to represent call state changes originating from the local device.

These new local events are now used in `CallEventObserver` instead of the generic `CallAcceptedEvent` and `CallRejectedEvent`, providing a clearer distinction for handling call lifecycle states like accepting a call. Additionally, `CallState` now fires these local events, improving the accuracy of event-driven logic.
@rahul-lohra rahul-lohra self-assigned this Dec 23, 2025
# Conflicts:
#	stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
#	stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt
Adds a reason when leaving a call from the `CallLifecycleManager`. This provides more context for why the call was ended, distinguishing between different scenarios such as the task being removed for an ongoing or incoming call.
@github-actions
Copy link
Contributor

github-actions bot commented Dec 23, 2025

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled.

🎉 Great job! This PR is ready for review.

@github-actions
Copy link
Contributor

github-actions bot commented Dec 23, 2025

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 11.94 MB 11.96 MB 0.02 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.27 MB 6.27 MB 0.00 MB 🟢

aleksandar-apostolov and others added 13 commits December 24, 2025 10:32
Renames several components within the `CallService` infrastructure to improve clarity by prefixing them with "CallService". This includes managers and observers.

The events `LocalCallAcceptedEvent` and `LocalCallRejectedEvent` are renamed to `LocalCallAcceptedPostEvent` and `LocalCallRejectedPostEvent` respectively to better reflect their purpose.

Additionally, this change introduces a comprehensive suite of unit tests for the `CallService` and its related components, covering:
- Lifecycle and notification management
- Event, ringing state, and notification update observers
- Foreground service permission handling
- Debouncer and Throttler utilities
Renames `LocalCallAcceptedEvent` and `LocalCallRejectedEvent` to `LocalCallAcceptedPostEvent` and `LocalCallRejectedPostEvent` respectively, to better reflect that they are events sent *after* the action has occurred.
Adds `notificationId` as a parameter to the `updateNotification` function. This allows for updating the notification with a specific ID. The previous `updateNotification` function without the ID has been deprecated.
Adds an overloaded version of the `updateNotification` method that doesn't require a notification ID, simplifying its usage.
The `transitionToAcceptCall` function in `ClientState` is now marked as `internal`, restricting its usage to within the same module. This change also updates the public API definition.
Removes an unnecessary `stopService` call that was being made immediately after `startService` in `ServiceLauncher`.
Adds a TODO comment to `DefaultNotificationDispatcher.kt` to ensure a new fix is verified before merging.
Adds a TODO comment to `DefaultNotificationDispatcher.kt` to ensure a new fix is verified before merging.
Adds the `RobolectricTestRunner` to `ServiceStateTest` to address test failures that occurred when running all tests together.

The `unregisterToggleCameraBroadcastReceiver` test has been moved to the end of the file as it was failing for unknown reasons, and a `println` has been added for debugging.
When a call is initiated, it now transitions through the `RingingState.Outgoing` state. This change ensures that a foreground service with an outgoing call notification is started, providing immediate feedback to the user that a call is being placed.

A delay is introduced before starting the foreground service for outgoing calls to allow the system to properly transition the call state.
This commit introduces several improvements to the handling of call notifications and the lifecycle of the `CallService`.

The `handleNotification` method in `CallService` now returns a `CallServiceHandleNotificationResult` enum. This allows for more granular control over the service's state, enabling it to continue running without re-initialization (`START_NO_CHANGE`) or to be redelivered if an error occurs.

Additionally, the logic for handling `LocalCallMissedEvent` has been moved from `CallServiceEventObserver` directly into the `CallState`. This change ensures that incoming call notifications are correctly dismissed when another call is already active, without unnecessarily stopping the foreground service.

Other changes include:
- Preventing the start of a new `CallService` if one is already running for another call.
- Adding a short delay before starting the foreground service when a call is accepted to prevent race conditions.
- Enhancing logging for better debugging of service and notification lifecycles.
Removes a leftover development comment in `ServiceLauncher.kt`.
Adds a clarifying comment in `ClientState.kt` to explain the logic for transitioning incoming/outgoing calls to an active state within the same service.
Removes temporary "Noob" log messages from `ClientState`, `CallServiceEventObserver`, and `CallService`. Also removes some commented-out code from `CallServiceEventObserver`.
Updates the `isServiceRunning` check to return `false` when the service is not running.
Removes the static `runningServiceClassName` set from `CallService` and updates the `isServiceRunning` check. The `ServiceIntentBuilder.isServiceRunning` method now uses `ActivityManager.getRunningServices()` to determine if a service is active. This provides a more reliable check based on the Android system's state.

The method signature for `isServiceRunning` has been updated to require a `Context` parameter.
Extracts the `Throttler` object from `Debouncer.kt` into its own dedicated file, `Throttler.kt`.

This change also moves the `debouncer` instance in `CallService` to be a top-level property and adds documentation explaining its purpose.
Throttles the `stopService` requests in `ServiceLauncher` to prevent the service from being stopped and restarted too frequently.

The throttling is set to `1000ms` using the new `SERVICE_DESTROY_THROTTLE_TIME_MS` constant defined in `CallService`.
This commit re-enables the `stopServiceGracefully()` call, which was previously commented out. The `source` parameter has also been removed from the `stopServiceGracefully()` method signature.
Removes a `// TODO` comment from `DefaultNotificationDispatcher`.
Retrieves the `notificationId` from the `call.state` when available, instead of calculating it. This ensures a consistent notification ID is used for ongoing, incoming, and outgoing call notifications within the `ServiceNotificationRetriever` and `CallServiceNotificationUpdateObserver`.
Updates the `ServiceNotificationRetrieverTest` to use the call-specific notification ID generated by `callId.getNotificationId()` instead of the call ID's hashcode. This aligns the test with the actual implementation for generating notification IDs.
When cancelling notifications for a call, the `CallServiceNotificationManager` now correctly uses the `notificationId` from the call's state.

Previously, it was attempting to cancel a hardcoded `Incoming` notification type ID, which might not be the correct one. This change ensures the notification that was actually shown is the one that gets cancelled.
Adds more detailed logging to the `CallServiceNotificationUpdateObserver` for better insight into notification updates.

Also, this change ensures that an existing notification ID from the call state is used before generating a new one when showing call notifications.
This commit refactors the `CallService` to improve clarity and maintainability.

Key changes include:
- Extracting the notification handling logic into smaller, more focused functions (`handleNullNotification`, `startForegroundForCall`).
- Passing the `serviceScope` to `CallServiceEventObserver` to ensure its lifecycle is tied to the service.
- Enhancing logging throughout the call service and related managers to provide more detailed information for debugging, including hashcodes to identify specific object instances.
- Adding `serviceScope.cancel()` in `onDestroy` to ensure all coroutines are properly cancelled when the service is destroyed.
@rahul-lohra rahul-lohra changed the title [wip] Improve Services Lifecycle Improve CallService lifecycle, notification handling, and ringing-to-active transition Jan 2, 2026
@rahul-lohra rahul-lohra added the pr:improvement Enhances an existing feature or code label Jan 2, 2026
Refactors the `IncomingCallPresenter` to simplify its logic by extracting functionality into smaller, more descriptive private functions. This improves readability and makes the different paths for handling incoming calls clearer.

The `buildStopIntent` method in `ServiceIntentBuilder` is updated to only return an intent if the corresponding service is actually running, preventing unnecessary stop commands.

Additionally, this commit:
- Removes the handling of `LocalCallMissedEvent` from `CallServiceEventObserver` as it's no longer needed.
- Enhances tests for `IncomingCallPresenter` and `ServiceIntentBuilder` to cover the new logic and edge cases.
@sonarqubecloud
Copy link

sonarqubecloud bot commented Jan 2, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot
56.4% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@rahul-lohra rahul-lohra marked this pull request as ready for review January 2, 2026 14:09
@rahul-lohra rahul-lohra requested a review from a team as a code owner January 2, 2026 14:09
@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 2026

Walkthrough

This pull request refactors the call notification and foreground service lifecycle management in the Android video SDK. It introduces new observer-driven components, permission managers, and state tracking to improve reliability of incoming call notifications and service lifecycle while adding notification ID tracking and enhanced event handling.

Changes

Cohort / File(s) Summary
Core Call State Management
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt, CallState.kt, ClientState.kt
Adds transitionToAcceptCall() helper for coordinating ringing-to-active state transitions; updates create(), accept(), and join() to use the new transition API instead of direct state manipulation; adds notification ID tracking and post-event firing (LocalCallAcceptedPostEvent, LocalCallRejectedPostEvent); enhances LocalCallMissedEvent handling with conditional notification cancellation.
Event Model Updates
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/events/LocalEvent.kt, stream-video-android-core/api/stream-video-android-core.api
Extends LocalCallMissedEvent data class to include createdById parameter; updates component functions and copy semantics accordingly; adds getCreatedById() getter method to expose the new field.
Notification Dispatch & Handlers
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/*, notifications/handlers/StreamDefaultNotificationHandler.kt
Updates DefaultNotificationDispatcher to use new updateNotification(id, notification) signature; replaces direct hashCode() usage with computed notification IDs via getNotificationId(NotificationType); extracts created_by_id from payloads for LocalCallMissedEvent; adds KDoc to NotificationDispatcher interface.
Service Lifecycle & Lifecycle Managers
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt, CallServiceLifecycleManager.kt, IncomingCallPresenter.kt, ServiceIntentBuilder.kt
Replaces ad-hoc service logic with refactored lifecycle manager; introduces CallServiceLifecycleManager to centralize call initialization, ringing state updates, and call termination; refactors IncomingCallPresenter with helper methods for permission checks and service branching; updates ServiceIntentBuilder to return nullable stop intents and expand logging.
Notification Management & State
CallServiceNotificationManager.kt, ServiceNotificationRetriever.kt, ServiceState.kt, ServiceLauncher.kt
Adds CallServiceNotificationManager to centralize notification dispatch and cancellation; updates ServiceNotificationRetriever to compute consistent notification IDs and accept optional display name; introduces ServiceState to track call ID, sound player, receiver registration, and timestamps; enhances ServiceLauncher with throttled stop mechanism and notification ID tracking.
Observer Pattern for Service Events
observers/CallServiceEventObserver.kt, CallServiceNotificationUpdateObserver.kt, CallServiceRingingStateObserver.kt
Introduces three observer classes to decouple event handling from service logic: CallServiceEventObserver monitors call events and connection state; CallServiceRingingStateObserver manages audio/vibration feedback for ringing states; CallServiceNotificationUpdateObserver observes notification triggers and updates foreground service presentation.
Permission Management Architecture
permissions/ForegroundServicePermissionManager.kt, permissions/AudioCallPermissionManager.kt, permissions/LivestreamCallPermissionManager.kt, permissions/LivestreamAudioCallPermissionManager.kt, permissions/LivestreamViewerPermissionManager.kt
Introduces new ForegroundServicePermissionManager base class to centralize foreground service type resolution across Android versions; replaces inline requiredForegroundTypes overrides with dedicated permission manager subclasses for audio calls, livestream calls, and livestream viewers; handles permission checks and fallback logic per API level.
Service Configuration & Models
notifications/internal/service/models/CallIntentParams.kt, ServiceState.kt, CallServiceHandleNotificationResult.kt
Adds new data classes and enums to encapsulate service parameters and state; CallIntentParams groups call intent metadata; ServiceState manages runtime call and receiver state; CallServiceHandleNotificationResult enumerates notification handling outcomes (START, START_NO_CHANGE, REDELIVER).
Helper Utilities
notifications/internal/Debouncer.kt, Throttler.kt
Introduces Debouncer utility for main-thread action debouncing with cancellation; adds Throttler utility for per-key cooldown management with caller-context-based key derivation and concurrent-safe tracking.
Legacy Service Updates
notifications/internal/service/AudioCallService.kt, LivestreamCallService.kt, LeaveCallBroadcastReceiver.kt
Replaces inline foreground type sets with permissionManager property for centralized permission handling; adds debug logging to LeaveCallBroadcastReceiver for notification ID tracking.
Demo App Configuration
demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt
Replaces inline AudioCall config lambda with static DefaultCallConfigurations.audioCall copied with enableTelecom = true.
Test Coverage
src/test/kotlin/io/getstream/video/android/core/notifications/internal/* (20+ test files)
Adds comprehensive unit tests for new components: Debouncer, Throttler, CallServiceLifecycleManager, CallServiceNotificationManager, CallServiceEventObserver, CallServiceRingingStateObserver, CallServiceNotificationUpdateObserver, all permission manager variants, and integration tests for IncomingCallPresenter and ServiceNotificationRetriever.

Sequence Diagram(s)

sequenceDiagram
    participant Service as CallService
    participant Lifecycle as CallServiceLifecycleManager
    participant EventObs as CallServiceEventObserver
    participant RingObs as CallServiceRingingStateObserver
    participant NotifObs as CallServiceNotificationUpdateObserver
    participant CallState as Call/CallState
    participant Client as StreamVideoClient

    Service->>Service: onCreate()
    Service->>Lifecycle: initializeCallAndSocket()
    Lifecycle->>Client: get call by ID
    Lifecycle->>CallState: connect socket
    Lifecycle-->>Service: onError() or success

    rect rgb(200, 220, 255)
        Note over Service,NotifObs: Incoming Call Ringing Flow
        Service->>EventObs: observe(onServiceStop, onRemoveIncoming)
        Service->>RingObs: observe(onStopService)
        Service->>NotifObs: startForegroundWithServiceType()
        
        RingObs->>CallState: subscribe to ringingState
        NotifObs->>CallState: subscribe to ringingState
        
        alt Incoming Ringing
            CallState-->>RingObs: Incoming state
            RingObs->>CallState: play sound + vibrate
            
            CallState-->>NotifObs: Incoming state
            NotifObs->>Service: startForegroundWithServiceType(INCOMING)
        end
    end

    rect rgb(220, 240, 220)
        Note over EventObs,Client: Event Handling
        CallState-->>EventObs: LocalCallAcceptedPostEvent
        alt Accepted on Another Device
            EventObs->>Service: onServiceStop()
        end
        
        CallState-->>EventObs: CallEndedEvent
        EventObs->>Service: onServiceStop()
    end

    rect rgb(240, 220, 220)
        Note over Service,Client: Service Cleanup
        alt Graceful Stop
            Service->>Service: stopServiceGracefully()
            Service->>Lifecycle: endCall()
            Lifecycle->>CallState: reject/leave call
        end
    end

    Service->>Service: onDestroy()
    Service->>RingObs: cleanup
    Service->>EventObs: cleanup
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

This refactoring introduces significant architectural changes to the call service lifecycle and notification handling system. The complexity stems from: (1) multiple interdependent new components (lifecycle managers, observers, permission managers) requiring careful interaction validation; (2) intricate state transitions and event flows across 5+ files in core call/state logic; (3) version-dependent permission handling logic across Android API levels; (4) extensive changes to notification ID tracking and service lifecycle—each requiring separate reasoning. While the changes follow cohesive patterns, the heterogeneity of new abstractions (observers, managers, throttlers) and the density of state management updates across files demand thorough review.

Suggested reviewers

  • PratimMallick

Poem

🐰 With gentle hops through notification flows,
New observers bloom where ringing goes,
Permission managers guard each service call,
While debounced stops bring order to it all,
State transitions smooth, lifecycle so bright—
A refactor done with care and with might!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.66% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main objectives of the changeset: improving CallService lifecycle management, notification handling, and the ringing-to-active call transition.
Description check ✅ Passed The description is well-structured and comprehensive, covering goals, implementation details with a clear table of class changes, testing instructions, and most required checklist items, though some sections like UI changes and GIF are not applicable to this backend refactor.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt (1)

19-33: Remove duplicate license header.

Lines 19-33 contain a duplicate of the license header that already appears at lines 1-15. This duplication should be removed.

🔎 Proposed fix
 package io.getstream.video.android.core.notifications.internal.service
 
-/*
- * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved.
- *
- * Licensed under the Stream License;
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *    https://github.com/GetStream/stream-video-android/blob/main/LICENSE
- *
- * 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.
- */
-
 import android.annotation.SuppressLint
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt (1)

465-477: Remove redundant direct assignment of _ringingCall.value before addRingingCall.

Lines 470 and 474 both set _ringingCall.value to the same call. Since addRingingCall() already assigns the value and additionally handles foreground service setup, the direct assignment at line 470 is redundant.

The comment claims that updateFromResponse reads _ringingCall, but inspection of that method shows it only updates call state fields and does not access ringingCall at all. Consolidating to a single call to addRingingCall would be clearer.

🧹 Nitpick comments (20)
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt (1)

61-64: Consider structured test coroutine patterns for TestScope.

The TestScope is created to satisfy mockStreamVideoClient.scope, but it's not leveraged with structured patterns like runTest or dispatcher control. If the code under test performs asynchronous work that needs deterministic execution in tests, consider using StandardTestDispatcher or UnconfinedTestDispatcher with proper test runners.

However, if ServiceNotificationRetriever operates synchronously or the current setup sufficiently covers the test scenarios, the current approach is acceptable.

Based on coding guidelines regarding structured coroutines and test best practices.

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/CallIntentParams.kt (1)

22-27: Consider using a sealed class or enum for the trigger parameter.

The trigger parameter is currently a String, which allows any value at runtime. Using a type-safe alternative would prevent invalid trigger values and improve compile-time safety.

🔎 Example refactor using a sealed class

Define a sealed class for triggers:

internal sealed class CallTrigger {
    data object IncomingCall : CallTrigger()
    data object OutgoingCall : CallTrigger()
    data object OngoingCall : CallTrigger()
    data object RemoveIncomingCall : CallTrigger()
}

Then update the data class:

 internal data class CallIntentParams(
     val streamVideo: StreamVideoClient,
     val callId: StreamCallId,
-    val trigger: String,
+    val trigger: CallTrigger,
     val displayName: String?,
 )
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt (2)

50-52: Log swallowed exceptions for diagnostics.

Static analysis flagged these silently swallowed exceptions. While failing silently may be intentional for robustness, logging at debug level preserves diagnostic info without impacting release builds.

🔎 Proposed fix to log exceptions
+import io.getstream.log.taggedLogger
+
 internal class ServiceState {
+    private val logger by taggedLogger("ServiceState")
 
     // ... existing fields ...
 
     internal fun registerToggleCameraBroadcastReceiver(service: Service, scope: CoroutineScope) {
         if (!isReceiverRegistered) {
             try {
                 // ... registration logic ...
                 isReceiverRegistered = true
             } catch (e: Exception) {
-                // Silently fail
+                logger.d(e) { "Failed to register camera broadcast receiver" }
             }
         }
     }
 
     internal fun unregisterToggleCameraBroadcastReceiver(service: Service) {
         if (isReceiverRegistered) {
             try {
                 toggleCameraBroadcastReceiver?.let { service.unregisterReceiver(it) }
                 isReceiverRegistered = false
             } catch (e: Exception) {
-                // Silently fail
+                logger.d(e) { "Failed to unregister camera broadcast receiver" }
             }
         }
     }
 }

Also applies to: 61-63


28-35: Consider thread safety for mutable state.

ServiceState holds mutable properties that may be accessed from different threads (e.g., main thread vs. coroutine scope). If concurrent access is expected, consider using @Volatile for primitive flags or AtomicReference for object references.

stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt (1)

53-66: Remove unused callServiceConfig variable.

The callServiceConfig initialized on line 66 with enableTelecom = true is never used—tests use config (line 59) instead.

🔎 Proposed fix
     private lateinit var context: Context
     private lateinit var serviceIntentBuilder: ServiceIntentBuilder
     private lateinit var presenter: IncomingCallPresenter
-    private lateinit var callServiceConfig: CallServiceConfig
     private lateinit var notification: Notification
     private lateinit var streamVideoClient: StreamVideoClient

     private val callId = StreamCallId("default", "123", "default:123")
     private val serviceClass = CallService::class.java
     private val config = CallServiceConfig(serviceClass = serviceClass)
 
     @Before
     fun setup() {
         MockKAnnotations.init(this, relaxed = true)
         context = mockk(relaxed = true)
         serviceIntentBuilder = mockk(relaxed = true)
-        callServiceConfig = CallServiceConfig(enableTelecom = true)
         notification = mockk(relaxed = true)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserver.kt (1)

42-49: Observer uses call.scope instead of constructor scope.

The observer collects ringingState on call.scope (line 43), but the constructor accepts a scope parameter. This inconsistency could cause lifecycle issues if the provided scope has different cancellation semantics. Consider using the provided scope consistently.

🔎 Proposed fix
     fun observe(onStopService: () -> Unit) {
-        call.scope.launch {
+        scope.launch {
             call.state.ringingState.collect { state ->
                 logger.i { "Ringing state: $state" }
                 handleRingingState(state, onStopService)
             }
         }
     }
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt (1)

136-144: Inconsistent scope usage between observers.

observeCallEvents (line 52) uses the constructor scope, but observeConnectionState (line 137) uses call.scope. Use the provided scope consistently for predictable lifecycle management.

🔎 Proposed fix
     private fun observeConnectionState(onServiceStop: () -> Unit) {
-        call.scope.launch {
+        scope.launch {
             call.state.connection.collectLatest { event ->
                 if (event is RealtimeConnection.Failed) {
                     handleConnectionFailure()
                 }
             }
         }
     }
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt (1)

183-190: Consider internal visibility for startForegroundWithServiceType.

This method is a simple delegation to onStartService. If it's only called within the module, marking it internal reduces public API surface.

🔎 Proposed fix
-    fun startForegroundWithServiceType(
+    internal fun startForegroundWithServiceType(
         notificationId: Int,
         notification: Notification,
         trigger: String,
         foregroundServiceType: Int,
     ) {
         onStartService(notificationId, notification, trigger, foregroundServiceType)
     }
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManagerTest.kt (1)

33-44: Test coverage is adequate for the current implementation.

The test correctly validates that both PHONE_CALL and MICROPHONE types are declared. While additional tests could verify parent class integration (e.g., androidQServiceType(), noPermissionServiceType() behavior across SDK versions as in LivestreamViewerPermissionManagerTest), the current coverage is sufficient since this class only overrides requiredForegroundTypes.

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt (1)

215-219: Prefer null-safe casting over broad exception handling.

The current try-catch swallows all exceptions silently. Using null-safe casting is more idiomatic in Kotlin and avoids masking unexpected errors.

🔎 Proposed fix
-        val createdByUserId = try {
-            payload["created_by_id"] as String
-        } catch (ex: Exception) {
-            ""
-        }
+        val createdByUserId = (payload["created_by_id"] as? String).orEmpty()
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt (1)

188-188: Optional: Remove leftover comment.

The comment // next appears to be a leftover debugging or TODO note.

🔎 Proposed cleanup
-    @Test // next
+    @Test
     fun `call ended stops service`() = runTest {
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserverTest.kt (1)

137-137: Clarify commented-out code.

The advanceUntilIdle() call is commented out without explanation. If this is intentional for testing timing behavior, a comment would help future maintainers understand why. If it's leftover debug code, it should be removed.

🔎 Suggested improvement

If intentional:

     fun `incoming ringing state starts incoming foreground notification`() = runTest {
         observer.observe(context)
-//        advanceUntilIdle()
+        // Don't advance here to test immediate notification behavior

If not needed:

     fun `incoming ringing state starts incoming foreground notification`() = runTest {
         observer.observe(context)
-//        advanceUntilIdle()
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt (1)

129-143: Missing mockkObject cleanup may cause test pollution.

The tests using mockkObject(StreamVideo.Companion) (lines 133, 150, 165, 179) do not unmock in @After. This can leak mocked state to other tests in the suite.

🔎 Proposed fix

Add cleanup in the tearDown method:

 @After
 fun tearDown() {
     Dispatchers.resetMain()
+    io.mockk.unmockkObject(StreamVideo.Companion)
 }

Alternatively, use unmockkAll() if other static mocks are also present.

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Throttler.kt (1)

63-67: Stack trace index 4 is brittle and may break with call site changes.

The hardcoded index assumes a specific call depth. Inline functions, lambda wrappers, or compiler optimizations could shift the index. The fallback to action.hashCode() mitigates this but loses caller context.

Consider requiring an explicit key for production callers to avoid relying on stack inspection.

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt (1)

56-59: Redundant let block on non-null parameter.

callId is already non-null in the function signature, so the let block adds no value.

🔎 Proposed simplification
 fun cancelNotifications(service: Service, callId: StreamCallId) {
     val notificationManager = NotificationManagerCompat.from(service)

-    callId.let {
-        logger.d { "[cancelNotifications], notificationId via hashcode: ${it.hashCode()}" }
-        notificationManager.cancel(it.hashCode())
-    }
+    logger.d { "[cancelNotifications], notificationId via hashcode: ${callId.hashCode()}" }
+    notificationManager.cancel(callId.hashCode())
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt (1)

102-126: Unused error parameter in showNotification.

The error parameter is passed but never used in the function body. Consider removing it or logging it for debugging purposes.

🔎 Proposed fix
 private fun showNotification(
     context: Context,
     notification: Notification?,
     callId: StreamCallId,
-    error: Any?,
 ): ShowIncomingCallResult {

Or log the error:

     if (!hasNotificationPermission(context) || notification == null) {
         logger.w {
             "[showIncomingCall] Cannot show notification - " +
                 "permission=${hasNotificationPermission(context)}, " +
-                "notification=${notification != null}"
+                "notification=${notification != null}, " +
+                "error=$error"
         }
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt (2)

63-91: Inconsistent StreamVideo access pattern.

initializeCallAndSocket and updateRingingCall receive streamVideo as a parameter, but endCall accesses StreamVideo.instanceOrNull() directly. Consider passing streamVideo as a parameter for consistency and testability.

🔎 Proposed signature change
-fun endCall(scope: CoroutineScope, callId: StreamCallId?) {
+fun endCall(scope: CoroutineScope, streamVideo: StreamVideo?, callId: StreamCallId?) {
     callId?.let { id ->
-        StreamVideo.Companion.instanceOrNull()?.let { streamVideo ->
+        streamVideo?.let {
             val call = streamVideo.call(id.type, id.id)

93-106: Magic number 2 for member count check.

The hardcoded memberCount == 2 assumes 1:1 call semantics. Consider extracting to a named constant with documentation explaining the business logic.

🔎 Proposed improvement
companion object {
    /** In a 1:1 call (2 members), rejecting ends the call for both parties */
    private const val ONE_TO_ONE_CALL_MEMBER_COUNT = 2
}

// Usage:
if (memberCount == ONE_TO_ONE_CALL_MEMBER_COUNT) {
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt (2)

108-110: Optional: Remove redundant exception string interpolation.

The exception is already passed to logger.e(exception), so including $exception in the message string is redundant. The logger will format the stack trace appropriately.

🔎 Proposed refinement
 val handler = CoroutineExceptionHandler { _, exception ->
-    logger.e(exception) { "[CallService#Scope] Uncaught exception: $exception" }
+    logger.e(exception) { "[CallService#Scope] Uncaught exception" }
 }

614-617: Address TODO: Verify sound ownership before stopping.

The TODO comment indicates uncertainty about which call owns the sound. This could cause issues if multiple calls are active. Consider tracking sound ownership explicitly or verifying the current call before stopping sound.

Do you want me to help design a solution for tracking sound ownership across multiple calls?

Comment on lines +3561 to +3569
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent;
public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallMissedEvent;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent;
public final fun component2 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent;
public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallMissedEvent;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent;
public fun equals (Ljava/lang/Object;)Z
public fun getCallCID ()Ljava/lang/String;
public final fun getCallCid ()Ljava/lang/String;
public final fun getCreatedById ()Ljava/lang/String;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for LocalCallMissedEvent constructor usages
rg -n 'LocalCallMissedEvent\s*\(' --type kotlin --type java -C 2

Repository: GetStream/stream-video-android

Length of output: 1654


🏁 Script executed:

#!/bin/bash
# Search for LocalCallMissedEvent in source files to understand the implementation
fd -e kt -e java | xargs grep -l 'class LocalCallMissedEvent' 2>/dev/null | head -10

Repository: GetStream/stream-video-android

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Check for CHANGELOG or migration documentation
fd -i 'changelog|release|migration|changes' -e md -e txt 2>/dev/null | head -20

Repository: GetStream/stream-video-android

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Search for deprecation annotations related to LocalCallMissedEvent
rg -n 'LocalCallMissedEvent' --type kotlin --type java | grep -i 'deprecated\|@Deprecated'

Repository: GetStream/stream-video-android

Length of output: 56


🏁 Script executed:

#!/bin/bash
# Check CHANGELOG for LocalCallMissedEvent or related changes
head -100 CHANGELOG.md

Repository: GetStream/stream-video-android

Length of output: 739


🏁 Script executed:

#!/bin/bash
# More comprehensive search for LocalCallMissedEvent usage (including tests)
rg 'LocalCallMissedEvent' --type kotlin --type java -A 1 -B 1

Repository: GetStream/stream-video-android

Length of output: 4498


🏁 Script executed:

#!/bin/bash
# Check if there are any example or test files
fd -e kt -e java | xargs grep -l 'LocalCallMissedEvent' 2>/dev/null

Repository: GetStream/stream-video-android

Length of output: 492


Add documentation of the breaking API change to the CHANGELOG.

The LocalCallMissedEvent constructor change from one parameter to two (adding createdById) is confirmed as intentional and properly implemented—all internal usages have been updated. However, the CHANGELOG has not been updated to document this breaking API change in the "Changed" section of stream-video-android before release.

Update CHANGELOG.md to document this change so consumers are aware of the migration needed if they instantiate LocalCallMissedEvent directly.

🤖 Prompt for AI Agents
In stream-video-android-core/api/stream-video-android-core.api around lines 3561
to 3569, the API shows LocalCallMissedEvent's constructor gained a second
parameter (createdById) but the CHANGELOG.md was not updated; add an entry in
stream-video-android/CHANGELOG.md under the "Changed" section documenting the
breaking API change: state that LocalCallMissedEvent's constructor signature
changed from (callCid) to (callCid, createdById), note this is intentional, and
include a short migration note instructing consumers to pass the new createdById
value when instantiating or update usage to the new factory/usage pattern.

Comment on lines 164 to 195
fun setActiveCall(call: Call) {
this._activeCall.value = call
removeRingingCall(call)
call.scope.launch {
/**
* Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service
*/
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
/**
* Transition incoming/outgoing call to active on the same service
*/
val ringingState = call.state.ringingState.value
when (ringingState) {
is RingingState.Incoming -> {
call.scope.launch {
transitionToAcceptCall(call)
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
is RingingState.Outgoing -> {
call.scope.launch {
transitionToAcceptCall(call)
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
else -> {
removeRingingCall(call)
call.scope.launch {
/**
* Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service
*/
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace hard-coded delays with event-driven synchronization.

The 500ms hard-coded delays are labeled as a "Temporary fix" and appear in multiple branches. This approach is fragile and could cause race conditions on slower devices or under load. The timing-based coordination suggests that maybeStartForegroundService might be executing before the service is ready to receive the call.

Consider refactoring to use proper event-driven synchronization (e.g., callbacks, suspendCancellableCoroutine, or a shared state flow) instead of arbitrary delays. If the delay must remain temporarily, extract it to a named constant with documentation explaining why it's needed and what permanent solution is planned.

🔎 Suggested refactor to extract the magic number
+private const val SERVICE_TRANSITION_DELAY_MS = 500L // TODO: Replace with event-driven sync

 fun setActiveCall(call: Call) {
     this._activeCall.value = call
-    /**
-     * Transition incoming/outgoing call to active on the same service
-     */
     val ringingState = call.state.ringingState.value
     when (ringingState) {
         is RingingState.Incoming -> {
             call.scope.launch {
                 transitionToAcceptCall(call)
-                delay(500L)
+                delay(SERVICE_TRANSITION_DELAY_MS)
                 maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
             }
         }
         is RingingState.Outgoing -> {
             call.scope.launch {
                 transitionToAcceptCall(call)
-                delay(500L)
+                delay(SERVICE_TRANSITION_DELAY_MS)
                 maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
             }
         }
         else -> {
             removeRingingCall(call)
             call.scope.launch {
-                /**
-                 * Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service
-                 */
-                delay(500L)
+                delay(SERVICE_TRANSITION_DELAY_MS)
                 maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
             }
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun setActiveCall(call: Call) {
this._activeCall.value = call
removeRingingCall(call)
call.scope.launch {
/**
* Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service
*/
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
/**
* Transition incoming/outgoing call to active on the same service
*/
val ringingState = call.state.ringingState.value
when (ringingState) {
is RingingState.Incoming -> {
call.scope.launch {
transitionToAcceptCall(call)
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
is RingingState.Outgoing -> {
call.scope.launch {
transitionToAcceptCall(call)
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
else -> {
removeRingingCall(call)
call.scope.launch {
/**
* Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service
*/
delay(500L)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
}
private const val SERVICE_TRANSITION_DELAY_MS = 500L // TODO: Replace with event-driven sync
fun setActiveCall(call: Call) {
this._activeCall.value = call
val ringingState = call.state.ringingState.value
when (ringingState) {
is RingingState.Incoming -> {
call.scope.launch {
transitionToAcceptCall(call)
delay(SERVICE_TRANSITION_DELAY_MS)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
is RingingState.Outgoing -> {
call.scope.launch {
transitionToAcceptCall(call)
delay(SERVICE_TRANSITION_DELAY_MS)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
else -> {
removeRingingCall(call)
call.scope.launch {
delay(SERVICE_TRANSITION_DELAY_MS)
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
}
}
}
}
🤖 Prompt for AI Agents
In
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt
around lines 164-195, the code uses repeated hard-coded 500ms delays to wait for
the call service which is fragile; replace these timing-based waits with
event-driven synchronization: create or use an observable/shared StateFlow or a
suspend function (e.g., suspendCancellableCoroutine) that completes when the
CallService reports it is ready/foregrounded, then await that signal before
calling maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL); if
you must keep a temporary delay, extract the 500L into a well-named constant
(e.g., CALL_SERVICE_STARTUP_WAIT_MS) with a comment explaining it is temporary
and reference the intended permanent solution (state flow / suspend await) so
future maintainers can remove it.

Comment on lines +22 to +35
internal class Debouncer {

private val handler = Handler(Looper.getMainLooper())
private var runnable: Runnable? = null

fun submit(delayMs: Long, action: () -> Unit) {
runnable?.let { handler.removeCallbacks(it) }

runnable = Runnable { action() }
runnable?.let {
handler.postDelayed(it, delayMs)
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add lifecycle management to prevent memory leaks.

The Debouncer lacks a cleanup mechanism. Since it uses a Handler tied to the main Looper and stores action lambdas that may capture lifecycle-scoped contexts (Activity/Fragment/Service), pending callbacks can execute after the owning component is destroyed, potentially causing memory leaks or crashes.

Consider adding a cancel() or clear() method:

🔎 Suggested lifecycle-safe implementation
 internal class Debouncer {
 
     private val handler = Handler(Looper.getMainLooper())
     private var runnable: Runnable? = null
 
     fun submit(delayMs: Long, action: () -> Unit) {
         runnable?.let { handler.removeCallbacks(it) }
 
         runnable = Runnable { action() }
         runnable?.let {
             handler.postDelayed(it, delayMs)
         }
     }
+
+    fun cancel() {
+        runnable?.let { handler.removeCallbacks(it) }
+        runnable = null
+    }
 }

Alternatively, consider using a coroutine-based approach with structured concurrency for automatic lifecycle management:

internal class Debouncer(private val scope: CoroutineScope) {
    private var job: Job? = null
    
    fun submit(delayMs: Long, action: () -> Unit) {
        job?.cancel()
        job = scope.launch {
            delay(delayMs)
            action()
        }
    }
    
    fun cancel() {
        job?.cancel()
        job = null
    }
}
🤖 Prompt for AI Agents
In
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt
around lines 22 to 35, the Debouncer currently posts Runnables to the main
Handler but lacks lifecycle cleanup, risking leaks or callbacks after a
component is destroyed; add a public cancel() (or clear()) method that calls
runnable?.let { handler.removeCallbacks(it) }, sets runnable = null, and ensure
callers (Activity/Fragment/Service) invoke this in their onDestroy/onCleared;
optionally provide/overload a close() or finalize-like API if used with
lifecycle owners, or replace with a CoroutineScope-backed implementation where
submit cancels the previous Job and cancel() cancels the job and nullls it.


call.leave("LeaveCallBroadcastReceiver")
val notificationId = intent.getIntExtra(INTENT_EXTRA_NOTIFICATION_ID, 0)
logger.d { "[onReceive], notificationId: notificationId" }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix string interpolation in log statement.

The log statement is missing the $ prefix for variable interpolation. It will log the literal string "notificationId" instead of the actual value.

🔎 Proposed fix
-        logger.d { "[onReceive], notificationId: notificationId" }
+        logger.d { "[onReceive], notificationId: $notificationId" }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.d { "[onReceive], notificationId: notificationId" }
logger.d { "[onReceive], notificationId: $notificationId" }
🤖 Prompt for AI Agents
In
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt
around line 42, the log currently prints the literal "notificationId" instead of
the variable; update the log string to interpolate the variable (e.g., change to
use $notificationId or ${notificationId} inside the string) so the actual
notificationId value is logged.

Comment on lines +111 to +112
val serviceScope: CoroutineScope =
CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Shared companion scope cancelled by instance lifecycle.

The serviceScope is a companion object shared across all CallService instances and subclasses, but it's cancelled in onDestroy() (line 559) and internalStopServiceGracefully() (line 618). This will prematurely cancel coroutines for other active service instances.

Each service instance should have its own scope tied to its lifecycle, not a shared static scope.

🔎 Suggested fix
-    val serviceScope: CoroutineScope =
-        CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob())

Move the scope to instance level:

+private lateinit var serviceScope: CoroutineScope
+
 override fun onCreate() {
     super.onCreate()
+    serviceScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob())
     serviceState.startTime = OffsetDateTime.now()
 }

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +101 to +119
@Test
fun `justNotify does nothing when permission is denied`() {
every {
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
} returns PackageManager.PERMISSION_DENIED

val streamVideo = mockk<StreamVideo>(relaxed = true)
every { StreamVideo.instanceOrNull() } returns streamVideo
every { streamVideo.getStreamNotificationDispatcher() } returns notificationDispatcher

sut.justNotify(
service = service,
callId = callId,
notificationId = 1001,
notification = notification,
)

verify { notificationDispatcher wasNot Called }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix incorrect variable reference.

Line 104 references an undefined context variable. The test setup only initializes service. This will cause a compilation or runtime error.

🔎 Proposed fix
     @Test
     fun `justNotify does nothing when permission is denied`() {
         every {
-            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
+            ContextCompat.checkSelfPermission(service, Manifest.permission.POST_NOTIFICATIONS)
         } returns PackageManager.PERMISSION_DENIED
🤖 Prompt for AI Agents
In
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt
around lines 101 to 119, the test references an undefined context variable when
stubbing ContextCompat.checkSelfPermission; update the test to use the Service's
context (e.g., service.applicationContext or service as Context) or initialize a
local context val from service before stubbing, and then use that concrete
context in the every { ContextCompat.checkSelfPermission(...) } call so the test
compiles and correctly simulates the permission denial.

Comment on lines +185 to +190
// @Test
fun `cancelNotifications is safe when callId is null`() {
// sut.cancelNotifications(service, null)

// verify { notificationManagerCompat wasNot Called }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove or complete the commented-out test.

This test is entirely commented out. If the scenario is not needed, remove it. If it's needed, complete the implementation.

🤖 Prompt for AI Agents
In
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt
around lines 185-190, the test function `cancelNotifications is safe when callId
is null` is fully commented out; either remove the dead test or restore and
implement it: uncomment the test, call sut.cancelNotifications(service, null)
and assert/verify that notificationManagerCompat was not called (consistent with
other tests' mocking/verify style), and ensure the test has the @Test annotation
and necessary mocks/setup; if the scenario is not required, delete the commented
block.

Comment on lines +103 to +108
/**
* Ignored because of unknown reason of failure, it will fail when running all tests
*/
@Test
fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() {
println("[unregisterToggleCameraBroadcastReceiver unregisters receiver]")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt | sed -n '100,115p'

Repository: GetStream/stream-video-android

Length of output: 712


Remove debug code and properly annotate or fix the flaky test.

The test at line 107 includes a comment indicating it fails intermittently when running all tests, but lacks an @Ignore annotation. Additionally, the println statement at line 108 should be removed.

Either:

  1. Add @Ignore with a reason documenting the known issue, or
  2. Investigate and fix the root cause of the intermittent failure
     /**
      * Ignored because of unknown reason of failure, it will fail when running all tests
      */
+    @Ignore("Intermittent failure when running all tests - needs investigation")
     @Test
     fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() {
-        println("[unregisterToggleCameraBroadcastReceiver unregisters receiver]")
         val sut = ServiceState()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Ignored because of unknown reason of failure, it will fail when running all tests
*/
@Test
fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() {
println("[unregisterToggleCameraBroadcastReceiver unregisters receiver]")
/**
* Ignored because of unknown reason of failure, it will fail when running all tests
*/
@Ignore("Intermittent failure when running all tests - needs investigation")
@Test
fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() {
val sut = ServiceState()
🤖 Prompt for AI Agents
In
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt
around lines 103 to 108, remove the debug println and either properly mark the
flaky test with @Ignore including a concise reason for the failure, or
investigate and fix the intermittent failure; the immediate action should be to
delete the println("[unregisterToggleCameraBroadcastReceiver unregisters
receiver]") and add @Ignore("flaky when running full test suite - tracked by
ISSUE-<id> or brief cause") above the test to prevent false CI failures, or
alternatively reproduce and correct the underlying timing/resource issue so the
test can remain enabled.

Comment on lines +167 to +190
fun `buildStopIntent returns intent with stop flag when service is running`() {
// given
val builder = spyk(ServiceIntentBuilder())

// given
val serviceClass = CallService::class.java
val config = CallServiceConfig(serviceClass = serviceClass)
val param = StopServiceParam(
callServiceConfiguration = config,
call = null,
)

// When
val intent = ServiceIntentBuilder().buildStopIntent(
context,
StopServiceParam(callServiceConfiguration = customConfig),
every {
builder.isServiceRunning(context, serviceClass)
} returns true

// when
val intent = builder.buildStopIntent(context, param)

// then
assertNotNull(intent)
assertEquals(serviceClass.name, intent!!.component?.className)
assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove duplicate "given" comment.

There are two consecutive "given" comments (lines 168 and 171). Remove one for consistency.

🔎 Proposed fix
     fun `buildStopIntent returns intent with stop flag when service is running`() {
-        // given
         val builder = spyk(ServiceIntentBuilder())
 
         // given
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun `buildStopIntent returns intent with stop flag when service is running`() {
// given
val builder = spyk(ServiceIntentBuilder())
// given
val serviceClass = CallService::class.java
val config = CallServiceConfig(serviceClass = serviceClass)
val param = StopServiceParam(
callServiceConfiguration = config,
call = null,
)
// When
val intent = ServiceIntentBuilder().buildStopIntent(
context,
StopServiceParam(callServiceConfiguration = customConfig),
every {
builder.isServiceRunning(context, serviceClass)
} returns true
// when
val intent = builder.buildStopIntent(context, param)
// then
assertNotNull(intent)
assertEquals(serviceClass.name, intent!!.component?.className)
assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false))
}
fun `buildStopIntent returns intent with stop flag when service is running`() {
val builder = spyk(ServiceIntentBuilder())
// given
val serviceClass = CallService::class.java
val config = CallServiceConfig(serviceClass = serviceClass)
val param = StopServiceParam(
callServiceConfiguration = config,
call = null,
)
every {
builder.isServiceRunning(context, serviceClass)
} returns true
// when
val intent = builder.buildStopIntent(context, param)
// then
assertNotNull(intent)
assertEquals(serviceClass.name, intent!!.component?.className)
assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false))
}
🤖 Prompt for AI Agents
In
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt
around lines 167 to 190, there are two consecutive "given" comments; remove the
duplicate so only one "given" comment remains before the setup block to keep
comments consistent and uncluttered.

Comment on lines +193 to 231
fun `buildStopIntent attaches call cid when call is present`() {
// given
val builder = spyk(ServiceIntentBuilder())

// given
val serviceClass = CallService::class.java
val config = CallServiceConfig(serviceClass = serviceClass)

val call = mockk<Call> {
every { type } returns "default"
every { id } returns "123"
every { cid } returns "default:123"
}

val param = StopServiceParam(
callServiceConfiguration = config,
call = call,
)

// Then
every {
builder.isServiceRunning(context, serviceClass)
} returns true

// when
val intent = builder.buildStopIntent(context, param)

// then
assertNotNull(intent)
// Note: The actual implementation has some complex logic for running services
// so we just verify the intent is created

val streamCallId =
intent!!.getParcelableExtra<StreamCallId>(INTENT_EXTRA_CALL_CID)

assertNotNull(streamCallId)
assertEquals("default", streamCallId!!.type)
assertEquals("123", streamCallId.id)
assertEquals("default:123", streamCallId.cid)

assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove duplicate "given" comment.

There are two consecutive "given" comments (lines 194 and 197). Remove one for consistency.

🔎 Proposed fix
     fun `buildStopIntent attaches call cid when call is present`() {
-        // given
         val builder = spyk(ServiceIntentBuilder())
 
         // given
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun `buildStopIntent attaches call cid when call is present`() {
// given
val builder = spyk(ServiceIntentBuilder())
// given
val serviceClass = CallService::class.java
val config = CallServiceConfig(serviceClass = serviceClass)
val call = mockk<Call> {
every { type } returns "default"
every { id } returns "123"
every { cid } returns "default:123"
}
val param = StopServiceParam(
callServiceConfiguration = config,
call = call,
)
// Then
every {
builder.isServiceRunning(context, serviceClass)
} returns true
// when
val intent = builder.buildStopIntent(context, param)
// then
assertNotNull(intent)
// Note: The actual implementation has some complex logic for running services
// so we just verify the intent is created
val streamCallId =
intent!!.getParcelableExtra<StreamCallId>(INTENT_EXTRA_CALL_CID)
assertNotNull(streamCallId)
assertEquals("default", streamCallId!!.type)
assertEquals("123", streamCallId.id)
assertEquals("default:123", streamCallId.cid)
assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false))
}
fun `buildStopIntent attaches call cid when call is present`() {
val builder = spyk(ServiceIntentBuilder())
// given
val serviceClass = CallService::class.java
val config = CallServiceConfig(serviceClass = serviceClass)
val call = mockk<Call> {
every { type } returns "default"
every { id } returns "123"
every { cid } returns "default:123"
}
val param = StopServiceParam(
callServiceConfiguration = config,
call = call,
)
every {
builder.isServiceRunning(context, serviceClass)
} returns true
// when
val intent = builder.buildStopIntent(context, param)
// then
assertNotNull(intent)
val streamCallId =
intent!!.getParcelableExtra<StreamCallId>(INTENT_EXTRA_CALL_CID)
assertNotNull(streamCallId)
assertEquals("default", streamCallId!!.type)
assertEquals("123", streamCallId.id)
assertEquals("default:123", streamCallId.cid)
assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false))
}
🤖 Prompt for AI Agents
In
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt
around lines 193 to 231, remove the duplicate "given" comment so there's only
one "given" heading before the setup block; simply delete the redundant comment
(either the first or second) to keep the test comments consistent and concise
while leaving the rest of the test code unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:improvement Enhances an existing feature or code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants