feat: full-screen incoming call screen with ringtone & vibration#452
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile OverviewGreptile SummaryImplements 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:
Issues found:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
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
|
| // 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.", | ||
| ) | ||
| } |
There was a problem hiding this 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
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.| 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) |
There was a problem hiding this 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
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.c9eb767 to
405749f
Compare
app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt
Outdated
Show resolved
Hide resolved
|
Thank you for this! Has been on my mental back burner since last week |
405749f to
b7a1323
Compare
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>
56fe888 to
4e5f13a
Compare
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>
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 observesCallCoordinatorstate and delegates toMainActivitywhen 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
fullScreenIntenttargetingIncomingCallActivity. This works automatically when the phone is locked. AcontentIntentis 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 launchesIncomingCallActivitydirectly using theSYSTEM_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 unlockedUSE_FULL_SCREEN_INTENT— was already implicitly available before Android 14, now declared explicitlyFiles changed
IncomingCallActivity.kt— new ActivityIncomingCallActivityScreen.kt— standalone Compose UI for the call screenAndroidManifest.xml— Activity declaration + new permissionsCallNotificationHelper.kt— notification now targetsIncomingCallActivity, added overlay/fullscreen permission helpersReticulumServiceBinder.kt— launches Activity from foreground service when overlay permission is grantedServiceReticulumProtocol.kt— launches Activity from UI process as fallback