package io.rebble.libpebblecommon.connection.endpointmanager import co.touchlab.kermit.Logger import io.rebble.libpebblecommon.LibPebbleConfigFlow import io.rebble.libpebblecommon.connection.CompanionApp import io.rebble.libpebblecommon.connection.ConnectedPebble import io.rebble.libpebblecommon.connection.PebbleIdentifier import io.rebble.libpebblecommon.database.dao.LockerEntryRealDao import io.rebble.libpebblecommon.database.entity.LockerEntry import io.rebble.libpebblecommon.di.ConnectionCoroutineScope import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope import io.rebble.libpebblecommon.disk.pbw.PbwApp import io.rebble.libpebblecommon.js.CompanionAppDevice import io.rebble.libpebblecommon.js.PKJSApp import io.rebble.libpebblecommon.locker.Locker import io.rebble.libpebblecommon.locker.LockerPBWCache import io.rebble.libpebblecommon.metadata.pbw.appinfo.PbwAppInfo import io.rebble.libpebblecommon.services.WatchInfo import io.rebble.libpebblecommon.services.app.AppRunStateService import io.rebble.libpebblecommon.services.appmessage.AppMessageData import io.rebble.libpebblecommon.services.appmessage.AppMessageService import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlin.coroutines.cancellation.CancellationException class CompanionAppLifecycleManager( private val lockerPBWCache: LockerPBWCache, private val lockerEntryDao: LockerEntryRealDao, private val appRunStateService: AppRunStateService, private val appMessagesService: AppMessageService, private val locker: Locker, private val connectionScope: ConnectionCoroutineScope, private val libPebbleConfigFlow: LibPebbleConfigFlow, private val libpebbleCoroutineScope: LibPebbleCoroutineScope ): ConnectedPebble.PKJS, ConnectedPebble.CompanionAppControl { companion object { private val logger = Logger.withTag(CompanionAppLifecycleManager::class.simpleName!!) } private lateinit var device: CompanionAppDevice private var activeAppScope: CoroutineScope = CoroutineScope(Job().also { it.cancel() }) private val runningApps: MutableStateFlow> = MutableStateFlow(emptyList()) @Deprecated("Use more generic currentCompanionAppSession instead and cast if necessary") override val currentPKJSSession: StateFlow = PKJSStateFlow(runningApps) override val currentCompanionAppSessions: StateFlow> get() = runningApps.asStateFlow() private suspend fun handleAppStop() { activeAppScope.cancel() runningApps.value.forEach { it.stop() } runningApps.value = emptyList() } private suspend fun handleNewRunningApp(lockerEntry: LockerEntry) { try { val exceptionHandler = CoroutineExceptionHandler { _, throwable -> logger.e(throwable) { "Unhandled exception in CompanionAppLifecycleManager-${lockerEntry.id}: ${throwable.message}" } } activeAppScope = connectionScope + Job() + CoroutineName("CompanionAppLifecycleManager-${lockerEntry.id}") + exceptionHandler val pbw = PbwApp(lockerPBWCache.getPBWFileForApp(lockerEntry.id, lockerEntry.version, locker)) if (runningApps.value.isNotEmpty()) { logger.w { "App ${lockerEntry.id} is already running, stopping it before starting a new one" } runningApps.value.forEach { it.stop() } } val newApps = createCompanionApps(pbw, lockerEntry) runningApps.value = newApps val appIncomingChannels = newApps.map { Channel(Channel.BUFFERED) } newApps.zip(appIncomingChannels).forEach { (app, channel) -> app.start(channel.receiveAsFlow()) } activeAppScope.launch { device.inboundAppMessages(lockerEntry.id).collect { message -> for (channel in appIncomingChannels) { channel.trySend(message) } } } } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.e(e) { "Failed to init Companion app for app ${lockerEntry.id}: ${e.message}" } handleAppStop() return } } private fun createCompanionApps( pbw: PbwApp, lockerEntry: LockerEntry ): List { return buildList { val pkjsApp = if (pbw.hasPKJS) { val jsPath = lockerPBWCache.getPKJSFileForApp(lockerEntry.id, lockerEntry.version) PKJSApp( device, jsPath, pbw.info, lockerEntry, connectionScope, ) } else null pkjsApp?.let { add(it) } if (libPebbleConfigFlow.value.watchConfig.appMessageToMultipleCompanions || pkjsApp == null) { createPlatformSpecificCompanionAppControl( device = device, appInfo = pbw.info, pkjsRunning = pkjsApp != null, connectionCoroutineScope = connectionScope, libPebbleCoroutineScope = libpebbleCoroutineScope, )?.let { add(it) } } } } fun init(identifier: PebbleIdentifier, watchInfo: WatchInfo) { this.device = CompanionAppDevice( identifier, watchInfo, appMessagesService ) appRunStateService.runningApp.onEach { handleAppStop() if (it != null) { val lockerEntry = lockerEntryDao.getEntry(it) lockerEntry?.let { if (!it.systemApp) { handleNewRunningApp(lockerEntry) } } } }.onCompletion { // Unsure if this is needed handleAppStop() }.launchIn(connectionScope) } } /** * Hack to keep backwards compatibilty with the old ConnectedPebble.PKJS interface. It creates a state flow that only * exposes PKJSApp instances */ @OptIn(ExperimentalForInheritanceCoroutinesApi::class) class PKJSStateFlow(private val runningAppStateFlow: StateFlow>): StateFlow { override val value: PKJSApp? get() = runningAppStateFlow.value.filterIsInstance().firstOrNull() override val replayCache: List get() = runningAppStateFlow.replayCache.map { it.filterIsInstance().firstOrNull() } override suspend fun collect(collector: FlowCollector): Nothing { runningAppStateFlow.map { it.filterIsInstance().firstOrNull() }.collect(collector) throw IllegalStateException("This collect should never stop because parent is a state flow") } } expect fun createPlatformSpecificCompanionAppControl( device: CompanionAppDevice, appInfo: PbwAppInfo, pkjsRunning: Boolean, libPebbleCoroutineScope: LibPebbleCoroutineScope, connectionCoroutineScope: ConnectionCoroutineScope, ): CompanionApp?