Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pre-commit:
commit-msg:
commands:
commit-msg-check:
run: bash tool/scripts/commit-msg-check.sh
run: bash tool/scripts/commit-msg-check.sh {1}

pre-push:
parallel: true
Expand Down
10 changes: 10 additions & 0 deletions lib/features/call/services/background_isolate_callbacks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ Future<PushNotificationIsolateManager> _getOrInit() async {
certificates: _context!.appCertificates.trustedCertificates,
logger: Logger('PushNotificationIsolateManager'),
);
// init() constructs WebtritSignalingService and wires up the event subscription.
// Hub discovery and FGS start happen in connect(), which is called from run().
_logger.info('_getOrInit: initialising signaling module...');
_manager!.init();
_logger.info('_getOrInit: init complete');

return _manager!;
}
Expand Down Expand Up @@ -83,6 +88,11 @@ Future<void> onPushNotificationSyncCallback(CallkeepIncomingCallMetadata? metada
_logger.severe('onPushNotificationSyncCallback: error=$e');
} finally {
await _disposeContext();
try {
await WebtritSignalingService.restoreService();
} catch (e, st) {
_logger.warning('restoreService() after push failed', e, st);
}
Comment thread
SERDUN marked this conversation as resolved.
}
}

Expand Down
66 changes: 52 additions & 14 deletions lib/features/call/services/isolate_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import 'package:webtrit_callkeep/webtrit_callkeep.dart';
import 'package:webtrit_signaling/webtrit_signaling.dart';
import 'package:webtrit_signaling_service/webtrit_signaling_service.dart';

import 'package:webtrit_phone/app/constants.dart';
import 'package:webtrit_phone/data/data.dart';
import 'package:webtrit_phone/models/models.dart';
import 'package:webtrit_phone/repositories/repositories.dart';
Expand All @@ -22,6 +21,10 @@ import '../models/jsep_value.dart';
/// to retrieve call state, handles missed-call logging and notifications, and
/// releases the incoming call service when all work is done.
/// Never reconnects — the isolate is short-lived by design.
///
/// On Android, signaling runs through the FGS hub so push isolate and Activity
/// share a single WebSocket connection. On iOS the connection runs directly in
/// the main isolate. Call [init] after construction and before [run].
class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegate {
PushNotificationIsolateManager({
required this.callLogsRepository,
Expand All @@ -31,7 +34,9 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
required this.certificates,
required this.logger,
}) : _pushService = callkeep {
_initSignaling();
// setBackgroundServiceDelegate is called in the constructor so callkeep can
// route performAnswerCall / performEndCall as soon as the object exists,
// before [init] is called.
_pushService.setBackgroundServiceDelegate(this);
}

Expand All @@ -43,8 +48,10 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat

final BackgroundPushNotificationService _pushService;

late final SignalingModule _signalingModule;
late final StreamSubscription<SignalingModuleEvent> _signalingSubscription;
// Assigned exactly once in [init], before any call to [run] or [close].
late SignalingModule _signalingModule;
late StreamSubscription<SignalingModuleEvent> _signalingSubscription;
bool _initialized = false;

/// Metadata from the incoming push notification.
/// Used as a fallback for missed-call display name and call logging.
Expand All @@ -69,28 +76,51 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
// Public API
// ---------------------------------------------------------------------------

/// Initialises the signaling module.
///
/// Must be called once after construction and before [run]. Constructs
/// [WebtritSignalingService] and wires up the event subscription. Hub
/// discovery and FGS start happen later when [connect] is called from
/// [run] via the Android plugin's [HubConnectionManager].
void init() {
Comment thread
SERDUN marked this conversation as resolved.
_initSignaling();
_initialized = true;
}

/// Connects to the signaling server, processes call state for the given push
/// notification [metadata], and returns a [Future] that completes after all
/// work is done (notifications shown, logs written, native service released).
Future<void> run(CallkeepIncomingCallMetadata? metadata) {
if (!_initialized) {
throw StateError('PushNotificationIsolateManager.run() called before init()');
}
_metadata = metadata;
_completer = Completer<void>();
logger.info('run: callId=${metadata?.callId}');
logger.info('run: callId=${metadata?.callId} isConnected=${_signalingModule.isConnected}');
// WebtritSignalingService.connect() is idempotent: the internal
// _startPending / _isConnected guard makes repeated calls safe.
// Always call it so HubConnectionManager starts FGS discovery on the
// first run() and is a no-op on any subsequent call.
_signalingModule.connect();
return _completer!.future;
}

/// Cancels all timers and pending requests, then disposes the signaling module.
Future<void> close() async {
logger.info(
'close: disposing module=${_initialized ? _signalingModule.runtimeType : "not initialized"} pendingRequests=${_pendingRequests.length}',
);
for (final pending in _pendingRequests) {
pending.timeoutTimer.cancel();
if (!pending.completer.isCompleted) {
pending.completer.completeError(StateError('PushNotificationIsolateManager closed'));
}
}
_pendingRequests.clear();
await _signalingSubscription.cancel();
await _signalingModule.dispose();
if (_initialized) {
await _signalingSubscription.cancel();
await _signalingModule.dispose();
}
await _releaseCall(_metadata?.callId);
_completeWithError(StateError('PushNotificationIsolateManager closed'));
}
Expand Down Expand Up @@ -122,14 +152,22 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
// Signaling init
// ---------------------------------------------------------------------------

/// Sets up [WebtritSignalingService] for this isolate in
/// [SignalingServiceMode.pushBound] mode — the same mechanism the Activity
/// uses, so push isolate and Activity share exactly one FGS WebSocket on
/// Android. [HubConnectionManager] inside the service handles FGS start and
/// hub discovery. [connect] is called from [run], not here, so the
/// connection starts only when processing begins.
void _initSignaling() {
_signalingModule = SignalingModuleImpl(
coreUrl: storage.readCoreUrl() ?? '',
tenantId: storage.readTenantId() ?? '',
token: storage.readToken() ?? '',
trustedCertificates: certificates,
connectionTimeout: kSignalingClientConnectionTimeout,
reconnectDelay: kSignalingClientReconnectDelay,
logger.info('_initSignaling: creating WebtritSignalingService (pushBound)');
_signalingModule = WebtritSignalingService(
config: SignalingServiceConfig(
coreUrl: storage.readCoreUrl() ?? '',
tenantId: storage.readTenantId() ?? '',
token: storage.readToken() ?? '',
trustedCertificates: certificates,
),
mode: SignalingServiceMode.pushBound,
);

_signalingSubscription = _signalingModule.events.listen((event) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,11 @@ class WebtritSignalingService implements SignalingModule {
/// Call on explicit user logout to prevent the service from reconnecting
/// with a stale token after the session ends. No-op on iOS.
static Future<void> stopService() => SignalingServicePlatform.instance.stopService();

/// Restores the persistent foreground service if it was killed by the OS.
///
/// Call from the push-notification callback after the temporary push WebSocket
/// is disposed to bring back the persistent connection for future calls.
/// No-op on iOS and when push mode is active or the service is already running.
Comment thread
SERDUN marked this conversation as resolved.
static Future<void> restoreService() => SignalingServicePlatform.instance.restoreService();
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class _FakePlatform extends Fake implements SignalingServicePlatform {
final List<SignalingModuleFactory> moduleFactories = [];
int disposeCount = 0;
int stopServiceCount = 0;
int restoreServiceCount = 0;

void inject(SignalingModuleEvent event) => _eventsController.add(event);

Expand Down Expand Up @@ -61,6 +62,9 @@ class _FakePlatform extends Fake implements SignalingServicePlatform {

@override
Future<void> stopService() async => stopServiceCount++;

@override
Future<void> restoreService() async => restoreServiceCount++;
}

class _VerifiedFakePlatform extends _FakePlatform with MockPlatformInterfaceMixin {}
Expand Down Expand Up @@ -312,5 +316,10 @@ void main() {
await WebtritSignalingService.stopService();
expect(platform.stopServiceCount, 1);
});

test('restoreService delegates to platform', () async {
await WebtritSignalingService.restoreService();
expect(platform.restoreServiceCount, 1);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ kotlin {

dependencies {
implementation "androidx.core:core-ktx:1.16.0"
implementation "androidx.work:work-runtime-ktx:2.9.0"

if (flutterSdkPath) {
compileOnly files("$flutterSdkPath/bin/cache/artifacts/engine/android-x86/flutter.jar")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,13 @@ interface PSignalingServiceHostApi {
* the service delivers the current status to the freshly-initialised isolate.
*/
fun notifyIsolateReady()
/**
* Restores the persistent foreground service if it was killed by the OS.
*
* No-op when push mode is active, the service is already running, credentials
* are missing (post-logout), or the callback dispatcher is not registered.
*/
fun connect()

companion object {
/** The codec used by PSignalingServiceHostApi. */
Expand Down Expand Up @@ -543,6 +550,22 @@ interface PSignalingServiceHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.webtrit_signaling_service_android.PSignalingServiceHostApi.connect$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.connect()
listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,12 @@ class SignalingForegroundService : Service() {

override fun onCreate() {
super.onCreate()
Log.d(TAG, "SignalingForegroundService onCreate")
startForeground()

Log.d(TAG, "SignalingForegroundService onCreate")
instance = this

val callbackHandle = StorageDelegate.getCallbackDispatcher(applicationContext)
flutterEngineHelper = FlutterEngineHelper(applicationContext, callbackHandle, this)

startForeground()
isRunning = true
}

Expand Down Expand Up @@ -97,6 +95,17 @@ class SignalingForegroundService : Service() {
}

override fun onDestroy() {
// Enqueue restart before any teardown so the job is queued while the process is still valid.
// Credentials guard: stopService() calls clearConnectionConfig() before stopping the service,
// so after explicit logout coreUrl is already empty here and no job is scheduled.
if (!StorageDelegate.isPushBound(applicationContext) &&
StorageDelegate.getCoreUrl(applicationContext).isNotEmpty() &&
StorageDelegate.getTenantId(applicationContext).isNotEmpty() &&
StorageDelegate.getToken(applicationContext).isNotEmpty() &&
StorageDelegate.getCallbackDispatcher(applicationContext) != 0L
) {
SignalingRestartWorker.enqueue(applicationContext, delayMillis = 15_000)
}
Comment thread
SERDUN marked this conversation as resolved.
Log.d(TAG, "SignalingForegroundService onDestroy")
instance = null
wakeLock?.let { if (it.isHeld) it.release() }
Expand All @@ -114,6 +123,12 @@ class SignalingForegroundService : Service() {
if (StorageDelegate.isPushBound(applicationContext)) {
Log.d(TAG, "pushBound mode -- stopping service on task removal")
gracefulStop { stopSelf() }
} else if (StorageDelegate.getCoreUrl(applicationContext).isNotEmpty() &&
StorageDelegate.getTenantId(applicationContext).isNotEmpty() &&
StorageDelegate.getToken(applicationContext).isNotEmpty() &&
StorageDelegate.getCallbackDispatcher(applicationContext) != 0L) {
// persistent mode -- enqueue a fast restart in case the OS doesn't honour START_STICKY
SignalingRestartWorker.enqueue(applicationContext, delayMillis = 1_000)
}
}

Expand Down Expand Up @@ -286,7 +301,7 @@ class SignalingForegroundService : Service() {
/// How long [gracefulStop] waits for an isolate ACK before forcing the stop.
private const val _gracefulStopTimeoutMs = 3000L

var isRunning = false
@Volatile var isRunning = false

/// The currently running service instance, set in [onCreate] and cleared in [onDestroy].
/// Used by [WebtritSignalingServicePlugin.notifyIsolateReady] so the plugin can trigger
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.webtrit.signaling_service

import android.content.Context
import android.os.Build
import android.util.Log
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit

/// WorkManager worker that restarts [SignalingForegroundService] after it is killed.
///
/// Enqueued by [SignalingForegroundService.onDestroy] (15 s delay) and
/// [SignalingForegroundService.onTaskRemoved] (1 s delay) when in persistent mode.
/// The [ExistingWorkPolicy.REPLACE] policy resets the timer if both triggers fire
/// close together, preventing duplicate restarts.
///
/// On Android 12+ (API 31+), [startForegroundService] can throw
/// [ForegroundServiceStartNotAllowedException] when the process has left the BFGS
/// window. This is a transient condition — [Result.retry] is returned so WorkManager
/// retries with exponential back-off until the process re-enters foreground.
/// All other exceptions indicate permanent failures and return [Result.failure]
/// to avoid unbounded retry loops.
class SignalingRestartWorker(
context: Context,
workerParams: WorkerParameters,
) : Worker(context, workerParams) {

override fun doWork(): Result = try {
if (!SignalingForegroundService.isRunning &&
!StorageDelegate.isPushBound(applicationContext) &&
StorageDelegate.getCoreUrl(applicationContext).isNotEmpty() &&
StorageDelegate.getTenantId(applicationContext).isNotEmpty() &&
StorageDelegate.getToken(applicationContext).isNotEmpty() &&
StorageDelegate.getCallbackDispatcher(applicationContext) != 0L
) {
Log.w(TAG, "SignalingRestartWorker: restarting persistent FGS")
SignalingForegroundService.start(applicationContext)
}
Result.success()
} catch (e: Exception) {
// ForegroundServiceStartNotAllowedException is expected on Android 12+ when the process
// has left the BFGS window. Log at warning level (transient) and schedule a retry.
// All other exceptions are permanent failures -- log at error level and stop retrying.
if (isForegroundServiceStartNotAllowed(e)) {
Log.w(TAG, "Cannot restart FGS: process not in BFGS state, will retry", e)
Result.retry()
} else {
Log.e(TAG, "Failed to restart FGS (permanent)", e)
Result.failure()
}
}

companion object {
private const val TAG = "SignalingRestartWorker"
private const val WORK_TAG = "signaling_fgs_restart"

fun enqueue(context: Context, delayMillis: Long = 15_000) {
val request = OneTimeWorkRequestBuilder<SignalingRestartWorker>()
.addTag(WORK_TAG)
.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_TAG, ExistingWorkPolicy.REPLACE, request)
}

fun remove(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_TAG)
}

Comment thread
SERDUN marked this conversation as resolved.
@Suppress("NewApi")
@androidx.annotation.RequiresApi(Build.VERSION_CODES.S)
private fun isFgsNotAllowed(e: Exception) =
e is android.app.ForegroundServiceStartNotAllowedException

private fun isForegroundServiceStartNotAllowed(e: Exception) =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isFgsNotAllowed(e)
}
}
Loading
Loading