Skip to content

feat: full-screen incoming call screen with ringtone & vibration#452

Merged
torlando-tech merged 14 commits intotorlando-tech:mainfrom
MatthieuTexier:feature/incoming-call-fullscreen
Feb 17, 2026
Merged

feat: full-screen incoming call screen with ringtone & vibration#452
torlando-tech merged 14 commits intotorlando-tech:mainfrom
MatthieuTexier:feature/incoming-call-fullscreen

Conversation

@MatthieuTexier
Copy link
Copy Markdown
Contributor

Adds a dedicated incoming call screen that shows when a call arrives, even if the app is closed or the phone is locked — similar to how the native phone app works.

How it works

  • IncomingCallActivity: a lightweight Activity (no Hilt, no navigation graph) that shows over the lock screen and turns the screen on. It observes CallCoordinator state and delegates to MainActivity when the user answers.

  • Ringtone & vibration: plays the default system ringtone (looping) and vibrates in a phone-call pattern (1s on / 1s off). Respects the device ringer mode (silent, vibrate, normal). Everything is cleaned up on answer, decline, or destroy.

  • Notification: the incoming call notification uses fullScreenIntent targeting IncomingCallActivity. This works automatically when the phone is locked. A contentIntent is also set so tapping the heads-up notification opens the call screen.

  • When the phone is unlocked and the app is closed: Android doesn't launch fullScreenIntent — it only shows a heads-up notification. To get around this, the foreground service launches IncomingCallActivity directly using the SYSTEM_ALERT_WINDOW ("Display over other apps") permission, which is one of the official exemptions for background activity starts on Android 10+. When the app is in the foreground, the launch happens from the UI process callback instead.

  • USE_FULL_SCREEN_INTENT: handled for Android 14+ where it became a runtime permission. Helpers are provided to check and redirect the user to system settings.

Permissions added

  • SYSTEM_ALERT_WINDOW — required to show the call screen when the app is closed and the phone is unlocked
  • USE_FULL_SCREEN_INTENT — was already implicitly available before Android 14, now declared explicitly

Files changed

  • IncomingCallActivity.kt — new Activity
  • IncomingCallActivityScreen.kt — standalone Compose UI for the call screen
  • AndroidManifest.xml — Activity declaration + new permissions
  • CallNotificationHelper.kt — notification now targets IncomingCallActivity, added overlay/fullscreen permission helpers
  • ReticulumServiceBinder.kt — launches Activity from foreground service when overlay permission is granted
  • ServiceReticulumProtocol.kt — launches Activity from UI process as fallback

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Feb 12, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 12, 2026

Greptile Overview

Greptile Summary

Implements a native phone-style incoming call screen that appears over the lock screen with ringtone and vibration support, using a lightweight Activity separate from the main app.

Key changes:

  • Created IncomingCallActivity that shows over lock screen, turns screen on, and plays ringtone/vibration respecting ringer mode
  • Activity launches via fullScreenIntent when device is locked, or via SYSTEM_ALERT_WINDOW permission from foreground service when unlocked
  • Added permission helpers for Android 14+ USE_FULL_SCREEN_INTENT runtime permission
  • Ringtone and vibration cleanup properly handled in onDestroy()
  • Memory leak fix: avoided requestDismissKeyguard() which captures Activity reference

Issues found:

  • Race condition: both service and UI process may launch the activity simultaneously for the same call
  • onNewIntent doesn't update UI when a new call arrives while activity is already showing
  • Ringtone won't loop on Android < P (API 28)

Confidence Score: 3/5

  • This PR adds significant functionality but has logic issues that could cause poor UX in production
  • Score reflects well-structured implementation with proper Android lifecycle handling and memory leak prevention, but the race condition between service and UI process launching the same activity, combined with the onNewIntent not updating UI, could lead to confusing behavior. The ringtone looping issue on older Android versions is a degraded experience but not critical.
  • Pay close attention to ReticulumServiceBinder.kt and IncomingCallActivity.kt for the race condition and UI update issues

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/IncomingCallActivity.kt added new lightweight Activity for incoming calls with ringtone/vibration, includes memory leak fix and proper cleanup, but has issues with ringtone looping on old Android and UI updates on new intents
app/src/main/java/com/lxmf/messenger/notifications/CallNotificationHelper.kt updated notification to target IncomingCallActivity instead of MainActivity, added permission helpers for full-screen intent and overlay, properly handles Android 14+ runtime permissions
app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt launches IncomingCallActivity from foreground service when overlay permission granted, potential race condition with UI process also launching the same activity
app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt launches IncomingCallActivity from UI process callback as fallback, missing caller name in intent extras

Sequence Diagram

sequenceDiagram
    participant Service as ReticulumService
    participant Notification as NotificationHelper
    participant FullScreen as fullScreenIntent
    participant Activity as IncomingCallActivity
    participant UI as IncomingCallScreen
    participant Main as MainActivity

    Note over Service: Incoming call arrives
    Service->>Notification: showIncomingCallNotification()
    Notification->>FullScreen: create fullScreenIntent
    
    alt Phone is LOCKED
        FullScreen->>Activity: launch (auto by system)
    else Phone is UNLOCKED
        Note over Service: fullScreenIntent doesn't fire
        Service->>Activity: startActivity() via SYSTEM_ALERT_WINDOW
    end

    Activity->>Activity: configureWindowForIncomingCall()
    Activity->>Activity: startRingtoneAndVibration()
    Activity->>UI: setContent(IncomingCallActivityScreen)
    
    alt User answers
        UI->>Activity: onAnswer()
        Activity->>Activity: stopRingtoneAndVibration()
        Activity->>Main: navigateToActiveCall()
        Main->>Main: show active call screen
        Activity->>Activity: finish()
    else User declines
        UI->>Activity: onDecline()
        Activity->>Activity: stopRingtoneAndVibration()
        Activity->>Activity: hangupCall()
        Activity->>Activity: finish()
    end
Loading

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

6 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +1285 to +1318
// Launch IncomingCallActivity directly from the foreground service.
// The fullScreenIntent only fires when the device is locked.
// When unlocked, Android shows only a heads-up notification.
// With SYSTEM_ALERT_WINDOW permission, the foreground service is
// allowed to start activities from the background (Android 10+).
if (android.provider.Settings.canDrawOverlays(context)) {
try {
val callScreenIntent =
android.content.Intent(
context,
com.lxmf.messenger.IncomingCallActivity::class.java,
).apply {
action = CallNotificationHelper.ACTION_OPEN_CALL
putExtra(CallNotificationHelper.EXTRA_IDENTITY_HASH, identityHash)
putExtra(
CallNotificationHelper.EXTRA_CALLER_NAME,
callerName ?: identityHash,
)
flags =
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
}
context.startActivity(callScreenIntent)
Log.i(TAG, "📞 Launched IncomingCallActivity directly (overlay permission granted)")
} catch (e: Exception) {
Log.w(TAG, "📞 Could not launch IncomingCallActivity: ${e.message}")
}
} else {
Log.w(
TAG,
"📞 Cannot launch IncomingCallActivity - overlay permission not granted. " +
"Falling back to notification only.",
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

both service and UI process may launch IncomingCallActivity simultaneously, causing duplicate screens

The service launches the activity here, and ServiceReticulumProtocol (line 561) also launches it from the UI process. Both can fire for the same incoming call, potentially creating two instances despite singleTask mode

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt
Line: 1285:1318

Comment:
both service and UI process may launch `IncomingCallActivity` simultaneously, causing duplicate screens

The service launches the activity here, and `ServiceReticulumProtocol` (line 561) also launches it from the UI process. Both can fire for the same incoming call, potentially creating two instances despite `singleTask` mode

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +125 to +128
super.onNewIntent(intent)
Log.d(TAG, "onNewIntent: action=${intent.action}")
// If we get a new incoming call intent while already showing, just update
setIntent(intent)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

onNewIntent doesn't update the UI with new caller info

When a new call comes in while the activity is already showing (via singleTask), setIntent(intent) updates the intent but the composable UI still shows the old caller's info from the initial onCreate. The screen won't reflect the new caller

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/IncomingCallActivity.kt
Line: 125:128

Comment:
`onNewIntent` doesn't update the UI with new caller info

When a new call comes in while the activity is already showing (via `singleTask`), `setIntent(intent)` updates the intent but the composable UI still shows the old caller's info from the initial `onCreate`. The screen won't reflect the new caller

How can I resolve this? If you propose a fix, please make it concise.

@MatthieuTexier MatthieuTexier force-pushed the feature/incoming-call-fullscreen branch from c9eb767 to 405749f Compare February 12, 2026 14:40
@torlando-tech
Copy link
Copy Markdown
Owner

Thank you for this! Has been on my mental back burner since last week

@torlando-tech torlando-tech force-pushed the feature/incoming-call-fullscreen branch from 405749f to b7a1323 Compare February 17, 2026 01:56
MatthieuTexier and others added 13 commits February 16, 2026 23:07
Add a lightweight dedicated Activity that displays the incoming call
screen over the lock screen, similar to the native phone app behavior.

Changes:
- IncomingCallActivity: lightweight Activity with showWhenLocked,
  turnScreenOn, and keyguard dismiss support
- IncomingCallActivityScreen: standalone Compose UI (no Hilt dependency)
  with pulsing avatar animation and answer/decline buttons
- AndroidManifest: declare IncomingCallActivity with separate task
  affinity and lock screen flags
- CallNotificationHelper: fullScreenIntent now targets
  IncomingCallActivity instead of heavy MainActivity

This ensures the incoming call screen appears instantly even when the
app is closed or the device is locked.
IncomingCallActivity:
- Play default ringtone (looping) when incoming call arrives
- Vibrate in phone-call pattern (1s on, 1s off, repeat)
- Respect device ringer mode (silent/vibrate/normal)
- Stop ringtone and vibration on answer, decline, or destroy

CallNotificationHelper:
- Add canUseFullScreenIntent() to check Android 14+ permission
- Add getFullScreenIntentSettingsIntent() to open system settings
- Log warning when USE_FULL_SCREEN_INTENT is not granted
The fullScreenIntent on notifications only triggers when the device is
locked. When unlocked, Android only shows a heads-up notification.

Now the service also launches IncomingCallActivity directly via
startActivity(), ensuring the full incoming call screen always appears
regardless of lock state.
The previous approach of calling startActivity() from the :reticulum
service process was silently blocked by Android 10+ background activity
launch restrictions.

Now IncomingCallActivity is launched from ServiceReticulumProtocol's
onIncomingCall callback, which runs in the main app process where
startActivity() is permitted.

Also:
- Revert the ineffective startActivity from ReticulumServiceBinder
- Add contentIntent to notification so tapping heads-up opens the call
- Bump notification priority to PRIORITY_MAX
When the app is closed and the phone is unlocked, Android 10+ blocks
startActivity() from background services. The fullScreenIntent on
notifications only launches the Activity when the device is locked.

Solution: use SYSTEM_ALERT_WINDOW ("Display over other apps") permission,
which is one of the official exemptions for background activity starts.

Changes:
- AndroidManifest: add SYSTEM_ALERT_WINDOW permission
- ReticulumServiceBinder: launch IncomingCallActivity directly from the
  foreground service when overlay permission is granted
- ServiceReticulumProtocol: also launch from UI process callback as
  fallback when app is in foreground
- CallNotificationHelper: add canDrawOverlays() and
  getOverlayPermissionSettingsIntent() helpers

The user must grant "Display over other apps" in system settings for
the incoming call screen to appear when the app is closed and the
phone is unlocked. Without it, only a heads-up notification is shown.
KeyguardManager.requestDismissKeyguard() captures the Activity in a
native IKeyguardDismissCallback$Stub that persists as a GC root even
after onDestroy(), leaking ~232 kB.

Fix:
- Remove requestDismissKeyguard from configureWindowForIncomingCall()
  (called on every onCreate, even when user doesn't answer)
- Move it to a new dismissKeyguardIfNeeded() method called only when
  the user actually answers the call
- Only dismiss if keyguard is actually locked
- Use explicit callback that clears references on completion
The previous fix (moving requestDismissKeyguard to answerCall with a
WeakReference callback) did not work because the leak is in the
framework's own anonymous class KeyguardManager$2, which captures the
Activity in its val$activity field. This is the first parameter passed
to requestDismissKeyguard(), not our callback - so we cannot prevent
the framework from retaining the Activity reference.

Solution: remove requestDismissKeyguard() entirely from
IncomingCallActivity. The keyguard dismissal will be handled by
MainActivity when it launches for the active call screen.

Also remove unused imports (KeyguardManager, Settings).
CallBridge and CallState were moved from
com.lxmf.messenger.reticulum.call.bridge to
tech.torlando.lxst.core (as CallCoordinator) in the LXST-kt submodule.

Update all references in IncomingCallActivity accordingly.
- Loop ringtone on pre-Android P using a coroutine that restarts
  playback when it stops (isLooping is only available on API 28+)
- Remove duplicate IncomingCallActivity launch from ServiceReticulumProtocol;
  the foreground service (ReticulumServiceBinder) already handles it via
  SYSTEM_ALERT_WINDOW, and the notification fullScreenIntent covers the
  locked-screen case
- Use Compose mutableStateOf for caller info so onNewIntent properly
  triggers UI recomposition with the new caller's identity and name
Cover lifecycle (finish on missing hash, survive with valid hash),
window configuration (FLAG_KEEP_SCREEN_ON), onNewIntent handling
(updates caller info, null-safe), full lifecycle without crashes,
and call state transitions (Idle/Ended/Rejected → finish).

Uses MockK to mock CallCoordinator singleton with Incoming state
so the activity doesn't auto-finish during test setup.
- Handle CallState.Busy as a finish trigger (was silently ignored)
- Fix callerName fallback passing raw hash instead of null
- Remove unused NotificationManager import
- Remove emoji from log messages for consistency
- Extract hardcoded strings to string resources for localization
- Extract hardcoded green color to named AnswerCallGreen constant
- Add unit test for CallState.Busy transition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace controller.setup() with controller.create().start() because
setup() calls visible() which idles the Robolectric main looper. The
IncomingCallActivityScreen uses rememberInfiniteTransition for the
pulsing avatar animation, which causes an infinite Choreographer frame
loop when the looper drains, hanging the test runner indefinitely.

Also adds UnconfinedTestDispatcher as Main so that lifecycleScope
coroutines (repeatOnLifecycle + StateFlow.collect) dispatch eagerly
on the current thread rather than through the looper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Call requestDismissKeyguard() when answering from lock screen so the
  mic is accessible (keyguard blocks hardware access even with
  setShowWhenLocked)
- Suppress the slide animation when transitioning from incoming call
  screen to active call screen using FLAG_ACTIVITY_NO_ANIMATION and
  overridePendingTransition(0, 0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech force-pushed the feature/incoming-call-fullscreen branch from 56fe888 to 4e5f13a Compare February 17, 2026 04:24
Surfaces RECORD_AUDIO, SYSTEM_ALERT_WINDOW, and USE_FULL_SCREEN_INTENT
permission status in Settings so users can discover and grant them.
Card shows dynamic red/green coloring based on grant state with 3s polling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit d782030 into torlando-tech:main Feb 17, 2026
21 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants