Skip to content

Commit c179b0b

Browse files
Merge pull request #1119 from torlando-tech/fix/1079-review-pass
Present incoming calls while backgrounded (issue #1079, replaces #1118)
2 parents 388a37c + c4e3ebc commit c179b0b

13 files changed

Lines changed: 1117 additions & 77 deletions

File tree

app/src/debug/java/network/columba/app/test/TestReceiver.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,26 @@ class TestReceiver : BroadcastReceiver() {
276276
}
277277
}
278278

279+
"network.columba.test.SHOW_INCOMING_CALL_TEST" -> {
280+
// Debug-only: post the real incoming-call FSI notification on demand
281+
// (issue #1079 diagnostics) so the full-screen takeover can be
282+
// reproduced without placing a real call. Optional extras:
283+
// hash - identity hash to carry (default: a test hash)
284+
// name - caller display name (default: "E2E Test Caller")
285+
val hash = intent.getStringExtra("hash") ?: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
286+
val name = intent.getStringExtra("name") ?: "E2E Test Caller"
287+
try {
288+
val helper = network.columba.app.notifications.CallNotificationHelper(app)
289+
helper.showIncomingCallNotification(hash, name)
290+
Log.i(
291+
TestController.LOGCAT_TAG,
292+
"fsi_test_posted hash=${hash.take(8)} name=$name",
293+
)
294+
} catch (e: Exception) {
295+
Log.e(TestController.LOGCAT_TAG, "fsi_test_err ${e.message}")
296+
}
297+
}
298+
279299
else ->
280300
Log.i(TestController.LOGCAT_TAG, "rx_broadcast_unknown action=$action")
281301
}

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
<!-- Voice call permissions (LXST Telephony) -->
8080
<uses-permission android:name="android.permission.RECORD_AUDIO" />
8181
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
82+
<!-- Incoming call screen vibration (IncomingCallActivity) -->
83+
<uses-permission android:name="android.permission.VIBRATE" />
8284
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
8385
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
8486
<!-- Required to show incoming call screen over other apps when phone is unlocked -->
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package network.columba.app
2+
3+
import android.os.Handler
4+
import android.os.Looper
5+
6+
/**
7+
* Minimal seam onto the application main looper for fire-and-forget work that
8+
* must share a single execution thread with the activity lifecycle callbacks
9+
* (see [MainActivityVisibility] for why confinement, not locking).
10+
*
11+
* A `post`d block runs on the main thread after any already-queued work,
12+
* preserving enqueue order. Unit tests replace [post] and [isMainThread] with a
13+
* synchronous fake (the real main looper does not exist on the JVM), the same
14+
* way `Dispatchers.setMain` swaps the coroutine main dispatcher.
15+
*/
16+
object AppMainThread {
17+
/** Test seam: replaced wholesale by unit tests; production uses the looper. */
18+
@Volatile
19+
var post: ((Runnable) -> Unit) = { runnable -> Handler(Looper.getMainLooper()).post(runnable) }
20+
21+
/** Test seam: whether the calling thread is the (faked) main thread. */
22+
@Volatile
23+
var isMainThread: () -> Boolean = { Looper.myLooper() == Looper.getMainLooper() }
24+
}

app/src/main/java/network/columba/app/ColumbaApplication.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ class ColumbaApplication : Application() {
7373
@Inject
7474
lateinit var messageCollector: MessageCollector
7575

76+
@Inject
77+
lateinit var incomingCallPresenter: network.columba.app.service.IncomingCallPresenter
78+
7679
@Inject
7780
lateinit var conversationRepository: ConversationRepository
7881

@@ -167,6 +170,13 @@ class ColumbaApplication : Application() {
167170

168171
android.util.Log.d("ColumbaApplication", "Main app process detected ($processName) - proceeding with auto-initialization")
169172

173+
// Present incoming calls even when the app is backgrounded or the device is
174+
// locked: the presenter posts the full-screen-intent notification on
175+
// CallState.Incoming (issue #1079). Started before backend init because the
176+
// bound callState flow is safe to observe before the service is ready - it
177+
// just stays Idle until the service connection syncs state.
178+
incomingCallPresenter.start()
179+
170180
// Preload theme preference into DataStore's in-memory cache
171181
// This eliminates theme flash on app startup by ensuring the theme is cached
172182
// before MainActivity renders. Combined with SplashScreen API for zero-flash UX.

app/src/main/java/network/columba/app/IncomingCallActivity.kt

Lines changed: 19 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ package network.columba.app
22

33
import android.content.Context
44
import android.content.Intent
5-
import android.media.AudioAttributes
65
import android.media.AudioManager
7-
import android.media.Ringtone
8-
import android.media.RingtoneManager
96
import android.os.Build
107
import android.os.Bundle
118
import android.os.VibrationEffect
@@ -33,10 +30,7 @@ import network.columba.app.notifications.CallNotificationHelper
3330
import network.columba.app.repository.SettingsRepository
3431
import network.columba.app.ui.screens.IncomingCallActivityScreen
3532
import network.columba.app.ui.theme.ThemeMode
36-
import kotlinx.coroutines.Job
3733
import dagger.hilt.android.EntryPointAccessors
38-
import kotlinx.coroutines.delay
39-
import kotlinx.coroutines.isActive
4034
import kotlinx.coroutines.launch
4135
import network.columba.app.di.RnsTelephonyEntryPoint
4236
import network.columba.app.rns.api.RnsTelephony
@@ -53,7 +47,7 @@ import network.columba.app.rns.api.model.CallState
5347
* - Shows over lock screen (showWhenLocked / FLAG_SHOW_WHEN_LOCKED)
5448
* - Turns screen on (turnScreenOn / FLAG_TURN_SCREEN_ON)
5549
* - Dismisses keyguard when answering
56-
* - Plays default ringtone and vibrates in a call pattern
50+
* - Vibrates in a call pattern (the ringtone is owned by the :reticulum service process)
5751
* - Retrieves the singleton [RnsTelephony] via Hilt's
5852
* [RnsTelephonyEntryPoint] (the Activity itself is kept Hilt-free so
5953
* cold start stays under the lock-screen ringing latency budget — see
@@ -75,8 +69,6 @@ class IncomingCallActivity : ComponentActivity() {
7569
.fromApplication(applicationContext, RnsTelephonyEntryPoint::class.java)
7670
.settingsRepository()
7771
}
78-
private var ringtone: Ringtone? = null
79-
private var ringtoneLoopJob: Job? = null
8072
private var vibrator: Vibrator? = null
8173

8274
// Compose-observable state so UI updates when onNewIntent delivers a new call
@@ -100,8 +92,11 @@ class IncomingCallActivity : ComponentActivity() {
10092
// Show over lock screen and turn screen on
10193
configureWindowForIncomingCall()
10294

103-
// Start ringtone and vibration
104-
startRingtoneAndVibration()
95+
// Vibrate in a phone-call pattern. The ringtone itself is played by the
96+
// :reticulum service process (LXST Telephone) which owns the call
97+
// lifecycle; playing a second ringtone here would double-ring while the
98+
// call screen is up.
99+
startVibration()
105100

106101
enableEdgeToEdge()
107102

@@ -160,7 +155,7 @@ class IncomingCallActivity : ComponentActivity() {
160155
}
161156

162157
override fun onDestroy() {
163-
stopRingtoneAndVibration()
158+
stopVibration()
164159
super.onDestroy()
165160
}
166161

@@ -173,9 +168,9 @@ class IncomingCallActivity : ComponentActivity() {
173168
if (newHash != null) {
174169
currentIdentityHash.value = newHash
175170
currentCallerName.value = newName
176-
// Restart ringtone/vibration for the new call
177-
stopRingtoneAndVibration()
178-
startRingtoneAndVibration()
171+
// Restart vibration for the new call
172+
stopVibration()
173+
startVibration()
179174
}
180175
}
181176

@@ -204,47 +199,15 @@ class IncomingCallActivity : ComponentActivity() {
204199
}
205200

206201
/**
207-
* Start playing the default ringtone and vibrating in a phone-call pattern.
208-
* Respects the device's ringer mode (silent/vibrate/normal).
202+
* Vibrate in a phone-call pattern. Respects the device's ringer mode (no
203+
* vibration in silent mode). The ringtone itself is played by the
204+
* :reticulum service process (LXST Telephone), which owns the call
205+
* lifecycle and stops it on answer/hangup.
209206
*/
210-
private fun startRingtoneAndVibration() {
207+
private fun startVibration() {
211208
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
212209
val ringerMode = audioManager.ringerMode
213210

214-
// Play ringtone (only if not in silent/vibrate mode)
215-
if (ringerMode == AudioManager.RINGER_MODE_NORMAL) {
216-
try {
217-
val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
218-
ringtone =
219-
RingtoneManager.getRingtone(this, ringtoneUri)?.apply {
220-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
221-
isLooping = true
222-
}
223-
audioAttributes =
224-
AudioAttributes
225-
.Builder()
226-
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
227-
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
228-
.build()
229-
play()
230-
}
231-
// On pre-P devices, isLooping is not available; manually restart
232-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && ringtone != null) {
233-
ringtoneLoopJob =
234-
lifecycleScope.launch {
235-
val rt = ringtone ?: return@launch
236-
while (isActive) {
237-
delay(1000)
238-
if (!rt.isPlaying) rt.play()
239-
}
240-
}
241-
}
242-
Log.d(TAG, "Ringtone started")
243-
} catch (e: Exception) {
244-
Log.e(TAG, "Error starting ringtone", e)
245-
}
246-
}
247-
248211
// Vibrate (in normal or vibrate mode, not silent)
249212
if (ringerMode != AudioManager.RINGER_MODE_SILENT) {
250213
try {
@@ -275,18 +238,9 @@ class IncomingCallActivity : ComponentActivity() {
275238
}
276239

277240
/**
278-
* Stop ringtone and vibration.
241+
* Stop vibration.
279242
*/
280-
private fun stopRingtoneAndVibration() {
281-
ringtoneLoopJob?.cancel()
282-
ringtoneLoopJob = null
283-
try {
284-
ringtone?.stop()
285-
ringtone = null
286-
Log.d(TAG, "Ringtone stopped")
287-
} catch (e: Exception) {
288-
Log.e(TAG, "Error stopping ringtone", e)
289-
}
243+
private fun stopVibration() {
290244
try {
291245
vibrator?.cancel()
292246
vibrator = null
@@ -306,7 +260,7 @@ class IncomingCallActivity : ComponentActivity() {
306260
*/
307261
private fun answerCall() {
308262
Log.i(TAG, "Answering call")
309-
stopRingtoneAndVibration()
263+
stopVibration()
310264
dismissKeyguardAndAnswer()
311265
}
312266

@@ -358,7 +312,7 @@ class IncomingCallActivity : ComponentActivity() {
358312
*/
359313
private fun declineCall() {
360314
Log.i(TAG, "Declining call")
361-
stopRingtoneAndVibration()
315+
stopVibration()
362316
lifecycleScope.launch {
363317
try {
364318
telephony.declineCall()

app/src/main/java/network/columba/app/MainActivity.kt

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import androidx.core.content.ContextCompat
6060
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
6161
import androidx.hilt.navigation.compose.hiltViewModel
6262
import androidx.lifecycle.compose.LocalLifecycleOwner
63+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
6364
import androidx.lifecycle.lifecycleScope
6465
import androidx.lifecycle.viewmodel.compose.viewModel
6566
import androidx.navigation.NavType
@@ -174,6 +175,9 @@ class MainActivity : ComponentActivity() {
174175
@Inject
175176
lateinit var transportAdmin: RnsTransportAdmin
176177

178+
@Inject
179+
lateinit var mainActivityVisibility: MainActivityVisibility
180+
177181
// State to hold pending navigation from intent
178182
private val pendingNavigation = mutableStateOf<PendingNavigation?>(null)
179183

@@ -278,6 +282,28 @@ class MainActivity : ComponentActivity() {
278282
}
279283
}
280284

285+
override fun onStart() {
286+
super.onStart()
287+
// Issue #1079: while the main UI is visible it owns incoming-call
288+
// presentation (in-app call screen); the background presenter stays
289+
// quiet so it can never duplicate or resurrect the notification. The
290+
// flag flip and this cancel run as one main-thread sequence (atomic
291+
// claim), so a background post can never land after this cancel and
292+
// survive it.
293+
mainActivityVisibility.claimForeground {
294+
CallNotificationHelper(this).cancelIncomingCallNotification()
295+
}
296+
}
297+
298+
override fun onStop() {
299+
// Rotation keeps the foreground claim (isChangingConfigurations) so the
300+
// background presenter never gets a post window mid-rotation; a real
301+
// backgrounding releases ownership, which lets the presenter take the
302+
// call back if it is still ringing (see IncomingCallPresenter).
303+
mainActivityVisibility.releaseForeground(isChangingConfigurations)
304+
super.onStop()
305+
}
306+
281307
override fun onCreate(savedInstanceState: Bundle?) {
282308
// Install splash screen before super.onCreate()
283309
// Splash screen will be displayed until theme is loaded, preventing flash
@@ -1167,7 +1193,16 @@ fun ColumbaNavigation(
11671193
.fromApplication(context.applicationContext, RnsTelephonyEntryPoint::class.java)
11681194
.telephony()
11691195
}
1170-
val callState by telephony.callState.collectAsState()
1196+
// Lifecycle-gated collection (issue #1079): a plain collectAsState() keeps
1197+
// updating while the activity is STOPPED, so the effect below would fire
1198+
// for a backgrounded app, navigate to the (invisible) IncomingCallScreen,
1199+
// and cancel the presenter's full-screen-intent notification before the
1200+
// system can show it. With collectAsStateWithLifecycle the observation
1201+
// pauses while the activity is not visible, leaving background
1202+
// presentation to IncomingCallPresenter; when the app is brought to the
1203+
// front mid-call, collection resumes on the current state and this effect
1204+
// takes over normally.
1205+
val callState by telephony.callState.collectAsStateWithLifecycle()
11711206

11721207
LaunchedEffect(callState) {
11731208
when (val state = callState) {

0 commit comments

Comments
 (0)