diff --git a/iosClient/iosClient/Info.plist b/iosClient/iosClient/Info.plist index 412e3781..5e8fff71 100644 --- a/iosClient/iosClient/Info.plist +++ b/iosClient/iosClient/Info.plist @@ -2,6 +2,12 @@ + BGTaskSchedulerPermittedIdentifiers + + network.bisq.mobile.ios.backgroundtask + + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -20,13 +26,16 @@ 1 LSRequiresIPhoneOS - CADisableMinimumFrameDurationOnPhone - UIApplicationSceneManifest UIApplicationSupportsMultipleScenes + UIBackgroundModes + + fetch + processing + UILaunchScreen UIRequiredDeviceCapabilities diff --git a/iosClient/iosClient/LifecycleAwareComposeViewController.swift b/iosClient/iosClient/LifecycleAwareComposeViewController.swift index 1b3221a1..6137ec30 100644 --- a/iosClient/iosClient/LifecycleAwareComposeViewController.swift +++ b/iosClient/iosClient/LifecycleAwareComposeViewController.swift @@ -10,7 +10,6 @@ class LifecycleAwareComposeViewController: UIViewController { init(presenter: MainPresenter) { self.presenter = presenter super.init(nibName: nil, bundle: nil) - presenter.attachView(view: self) } required init?(coder: NSCoder) { @@ -64,6 +63,7 @@ class LifecycleAwareComposeViewController: UIViewController { // Notify the child view controller that it was moved to a parent mainViewController.didMove(toParent: self) + presenter.attachView(view: self) } // Equivalent to `onDestroy` in Android for final cleanup diff --git a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.ios.kt b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.ios.kt index 1ceb6b89..a0b2ced5 100644 --- a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.ios.kt +++ b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.ios.kt @@ -1,68 +1,107 @@ -package network.bisq.mobile.domain.service.controller + package network.bisq.mobile.domain.service.controller -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import network.bisq.mobile.utils.Logging -import platform.BackgroundTasks.* + import kotlinx.cinterop.ExperimentalForeignApi + import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import network.bisq.mobile.utils.Logging + import platform.BackgroundTasks.* + import platform.Foundation.NSUUID + import platform.Foundation.setValue + import platform.UserNotifications.* -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -actual class NotificationServiceController: ServiceController, Logging { - private val logScope = CoroutineScope(Dispatchers.Main) + @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + actual class NotificationServiceController: ServiceController, Logging { - actual override fun startService() { - logDebug("Starting background service") - BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier(identifier = "network.bisq.mobile.ios.backgroundtask", usingQueue = null) { task -> - handleBackgroundTask(task as BGProcessingTask) - } - scheduleBackgroundTask() - logDebug("Background service started") - } + private var isRunning = false - actual override fun stopService() { - BGTaskScheduler.sharedScheduler.cancelAllTaskRequests() - logDebug("Background service stopped") - } + private val logScope = CoroutineScope(Dispatchers.Main) - actual fun pushNotification(title: String, message: String) { -// TODO -// val content = UNMutableNotificationContent().apply { -// this.title = title -// this.body = message +// TODO foreground notifications? +// UNUserNotificationCenter.currentNotificationCenter().delegate = object : UNUserNotificationCenterDelegateProtocol { +// override fun userNotificationCenter( +// center: UNUserNotificationCenter, +// willPresentNotification: UNNotification, +// withCompletionHandler: (UNNotificationPresentationOptions) -> Unit +// ) { +// withCompletionHandler(UNNotificationPresentationOptionsAlert or UNNotificationPresentationOptionsSound) +// } // } -// val request = UNNotificationRequest.requestWithIdentifier( -// NSUUID().UUIDString, -// content, -// null -// ) -// UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(request, null) - } - actual override fun isServiceRunning(): Boolean { - // iOS doesn't allow querying background task state directly - return false - } + actual override fun startService() { + if (isRunning) { + return + } + logDebug("Starting background service") + UNUserNotificationCenter.currentNotificationCenter().requestAuthorizationWithOptions( + UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge + ) { granted, error -> + if (granted) { + logDebug("Notification permission granted.") - private fun handleBackgroundTask(task: BGProcessingTask) { - logDebug("Executing background task") - task.setTaskCompletedWithSuccess(true) - scheduleBackgroundTask() // Reschedule if needed - } +// TODO need to move to iOS callback -> didFinishLaunchingWithOptions + BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier(identifier = "network.bisq.mobile.ios.backgroundtask", usingQueue = null) { task -> + handleBackgroundTask(task as BGProcessingTask) + } + scheduleBackgroundTask() + logDebug("Background service started") + isRunning = true + } else { + logDebug("Notification permission denied: ${error?.localizedDescription}") + } + } + } - @OptIn(ExperimentalForeignApi::class) - private fun scheduleBackgroundTask() { - val request = BGProcessingTaskRequest("com.yourapp.backgroundtask").apply { - requiresNetworkConnectivity = true + actual override fun stopService() { + BGTaskScheduler.sharedScheduler.cancelAllTaskRequests() + logDebug("Background service stopped") + isRunning = false + } + + actual fun pushNotification(title: String, message: String) { + val content = UNMutableNotificationContent().apply { + setValue(title, forKey = "title") + setValue(message, forKey = "body") + } + + val request = UNNotificationRequest.requestWithIdentifier( + NSUUID().UUIDString, // Generates a unique identifier + content, + null // Trigger can be set to null for immediate delivery + ) + UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(request) { error -> + if (error != null) { + println("Error adding notification request: ${error.localizedDescription}") + } else { + println("Notification added successfully") + } + } + } + + actual override fun isServiceRunning(): Boolean { + // iOS doesn't allow querying background task state directly + return isRunning + } + + private fun handleBackgroundTask(task: BGProcessingTask) { + logDebug("Executing background task") + task.setTaskCompletedWithSuccess(true) + scheduleBackgroundTask() // Reschedule if needed + } + + @OptIn(ExperimentalForeignApi::class) + private fun scheduleBackgroundTask() { + val request = BGProcessingTaskRequest("com.yourapp.backgroundtask").apply { + requiresNetworkConnectivity = true + } + BGTaskScheduler.sharedScheduler.submitTaskRequest(request, null) + logDebug("Background task scheduled") } - BGTaskScheduler.sharedScheduler.submitTaskRequest(request, null) - logDebug("Background task scheduled") - } - private fun logDebug(message: String) { - logScope.launch { - log.d { message } + private fun logDebug(message: String) { + logScope.launch { + log.d(message) + } } } -} diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/MainPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/MainPresenter.kt index 9c64f888..44605aa2 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/MainPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/MainPresenter.kt @@ -1,6 +1,9 @@ package network.bisq.mobile.presentation +import androidx.annotation.CallSuper import androidx.navigation.NavHostController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import network.bisq.mobile.android.node.BuildNodeConfig @@ -8,6 +11,7 @@ import network.bisq.mobile.client.shared.BuildConfig import network.bisq.mobile.domain.getPlatformInfo import network.bisq.mobile.domain.service.controller.NotificationServiceController import network.bisq.mobile.presentation.ui.AppPresenter +import kotlin.random.Random /** @@ -40,9 +44,22 @@ open class MainPresenter(private val notificationServiceController: Notification log.i { "iOS Client Version: ${BuildConfig.IOS_APP_VERSION}" } log.i { "Android Client Version: ${BuildConfig.IOS_APP_VERSION}" } log.i { "Android Node Version: ${BuildNodeConfig.APP_VERSION}" } + } + + @CallSuper + override fun onViewAttached() { + super.onViewAttached() notificationServiceController.startService() -// CoroutineScope(BackgroundDispatcher).launch { -// } + // sample code for push notifications sends a random message every 10 secs + CoroutineScope(BackgroundDispatcher).launch { + while (notificationServiceController.isServiceRunning()) { + val randomTitle = "Title ${Random.nextInt(1, 100)}" + val randomMessage = "Message ${Random.nextInt(1, 100)}" + notificationServiceController.pushNotification(randomTitle, randomMessage) + println("Pushed: $randomTitle - $randomMessage") + delay(10000) // 10 seconds + } + } } // Toggle action diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/App.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/App.kt index 202cff8e..c37704ba 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/App.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/App.kt @@ -10,6 +10,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview import kotlinx.coroutines.flow.StateFlow import network.bisq.mobile.presentation.ViewPresenter import network.bisq.mobile.presentation.ui.components.SwipeBackIOSNavigationHandler +import network.bisq.mobile.presentation.ui.helpers.RememberPresenterLifecycle import org.koin.compose.koinInject import network.bisq.mobile.presentation.ui.navigation.Routes @@ -41,20 +42,12 @@ fun App() { var isNavControllerSet by remember { mutableStateOf(false) } val presenter: AppPresenter = koinInject() - DisposableEffect(Unit) { -// For the main presenter use case we leave this for the moment the activity/viewcontroller respectively gets attached -// presenter.onViewAttached() + RememberPresenterLifecycle(presenter, { getKoin().setProperty("RootNavController", rootNavController) getKoin().setProperty("TabNavController", tabNavController) presenter.setNavController(rootNavController) isNavControllerSet = true - - onDispose { - // Optional cleanup logic -// getKoin().setProperty("RootNavController", null) -// getKoin().setProperty("TabNavController", null) - } - } + }) val lyricist = rememberStrings() diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/helpers/LifecycleHelper.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/helpers/LifecycleHelper.kt index d7814c04..2200419c 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/helpers/LifecycleHelper.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/helpers/LifecycleHelper.kt @@ -4,13 +4,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import network.bisq.mobile.presentation.ViewPresenter +/** + * @param presenter + * @param onExecute callback after view attached + * @param onDispose callback before on view unnattaching + */ @Composable -fun RememberPresenterLifecycle(presenter: ViewPresenter) { +fun RememberPresenterLifecycle(presenter: ViewPresenter, onExecute: (() -> Unit)? = null, onDispose: (() -> Unit)? = null) { DisposableEffect(presenter) { presenter.onViewAttached() // Called when the view is attached + onExecute?.let { + onExecute() + } onDispose { presenter.onViewUnattaching() // Called when the view is detached + onDispose?.let { + onDispose() + } } } } \ No newline at end of file