From 5f508b16e5f0b220994c7aef5f65f42e014056cc Mon Sep 17 00:00:00 2001 From: rodvar Date: Tue, 10 Dec 2024 16:01:54 +1100 Subject: [PATCH] Feature/resilient background service (#89) * Bisq Background/Foreground Service - Controller and service definitions - Implementation for Android - Permissions setup including dynamic permissions for OS that command so * - notifications and silent notification when there is nothing to say * - lower polling interval whilst on dev * - fix issues on ios, setup permissions - more memory for idea intellij backend (fleet config) * - added logs for ios notifications logic * Fix - At this stage foreground notification work However, background notification don't work because of ID of app changes * - merge nish fixes for iOS * - merge work from nish * - fix crashes on real devices when no connectivity * - check for tasks regularly (ios impl) + fix debug log calls * - flag to turn on/off testing code, turning off by default before merge --------- Co-authored-by: Nish --- .../src/androidMain/AndroidManifest.xml | 11 ++ .../bisq/mobile/client/MainActivity.kt | 44 +++++ .../bisq/mobile/client/MainApplication.kt | 3 +- .../src/androidMain/AndroidManifest.xml | 11 ++ .../bisq/mobile/android/node/MainActivity.kt | 46 ++++- .../mobile/android/node/MainApplication.kt | 3 +- .../android/node/di/AndroidNodeModule.kt | 2 +- .../NodeMarketPriceServiceFacade.kt | 8 +- .../NodeSelectedOfferbookMarketService.kt | 4 +- .../node/presentation/NodeMainPresenter.kt | 4 +- gradle/libs.versions.toml | 2 + iosClient/iosClient/Info.plist | 13 +- .../LifecycleAwareComposeViewController.swift | 2 +- iosClient/iosClient/iosClient.swift | 4 +- shared/domain/build.gradle.kts | 6 + .../mobile/domain/di/DomainModule.android.kt | 9 + .../domain/service/BisqForegroundService.kt | 126 +++++++++++++ .../NotificationServiceController.android.kt | 83 +++++++++ .../market/ClientMarketPriceServiceFacade.kt | 2 +- .../market/ClientMarketListItemService.kt | 6 +- .../offer/ClientOfferbookListItemService.kt | 2 +- .../bisq/mobile/domain/di/DomainModule.kt | 4 + .../NotificationServiceController.kt | 12 ++ .../service/controller/ServiceController.kt | 10 ++ .../bisq/mobile/domain/di/DomainModule.ios.kt | 2 + .../NotificationServiceController.ios.kt | 169 ++++++++++++++++++ .../bisq/mobile/client/ClientMainPresenter.kt | 4 +- .../bisq/mobile/presentation/MainPresenter.kt | 37 +++- .../presentation/di/PresentationModule.kt | 2 +- .../bisq/mobile/presentation/ui/App.kt | 13 +- .../ui/helpers/LifecycleHelper.kt | 13 +- .../ui/uicases/GettingStartedPresenter.kt | 1 - .../di/DependenciesProviderHelper.kt | 3 + 33 files changed, 625 insertions(+), 36 deletions(-) create mode 100644 shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/di/DomainModule.android.kt create mode 100644 shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/BisqForegroundService.kt create mode 100644 shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.android.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/ServiceController.kt create mode 100644 shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.ios.kt diff --git a/androidClient/src/androidMain/AndroidManifest.xml b/androidClient/src/androidMain/AndroidManifest.xml index 7696d689..1099348c 100644 --- a/androidClient/src/androidMain/AndroidManifest.xml +++ b/androidClient/src/androidMain/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + + + + \ No newline at end of file diff --git a/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainActivity.kt b/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainActivity.kt index c34513bf..2de76992 100644 --- a/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainActivity.kt +++ b/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainActivity.kt @@ -1,19 +1,29 @@ package network.bisq.mobile.client +import android.content.pm.PackageManager import android.graphics.Color +import android.os.Build import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.core.content.ContextCompat import network.bisq.mobile.presentation.MainPresenter import network.bisq.mobile.presentation.ui.App import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val presenter: MainPresenter by inject() + + // TODO probably better to handle from presenter once the user reach home + private lateinit var requestPermissionLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -23,6 +33,8 @@ class MainActivity : ComponentActivity() { setContent { App() } + + handleDynamicPermissions() } override fun onStart() { @@ -50,6 +62,38 @@ class MainActivity : ComponentActivity() { presenter.onDestroy() super.onDestroy() } + + private fun handleDynamicPermissions() { + requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + // Permission granted, proceed with posting notifications + } else { + // Permission denied, show a message to the user + Toast.makeText(this, "Permission denied. Notifications won't be sent.", Toast.LENGTH_SHORT).show() + } + } + + // Call the method to check and request permission in APIs where its mandatory + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkAndRequestNotificationPermission() + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun checkAndRequestNotificationPermission() { + // Check if the permission is granted + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted, proceed with posting notifications + } else { + // Request permission if not granted + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } } @Preview diff --git a/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainApplication.kt b/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainApplication.kt index 6e3599a2..0da93378 100644 --- a/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainApplication.kt +++ b/androidClient/src/androidMain/kotlin/network/bisq/mobile/client/MainApplication.kt @@ -4,6 +4,7 @@ import android.app.Application import network.bisq.mobile.client.di.androidClientModule import network.bisq.mobile.client.di.clientModule import network.bisq.mobile.domain.di.domainModule +import network.bisq.mobile.domain.di.serviceModule import network.bisq.mobile.presentation.di.presentationModule import org.koin.android.ext.koin.androidContext @@ -15,7 +16,7 @@ class MainApplication: Application() { startKoin { androidContext(this@MainApplication) - modules(listOf(domainModule, presentationModule, clientModule, androidClientModule)) + modules(listOf(domainModule, serviceModule, presentationModule, clientModule, androidClientModule)) } } } diff --git a/androidNode/src/androidMain/AndroidManifest.xml b/androidNode/src/androidMain/AndroidManifest.xml index 8337a91c..df6fe268 100644 --- a/androidNode/src/androidMain/AndroidManifest.xml +++ b/androidNode/src/androidMain/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + + + + \ No newline at end of file diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainActivity.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainActivity.kt index 95877343..4e7f4f60 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainActivity.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainActivity.kt @@ -1,10 +1,17 @@ package network.bisq.mobile.android.node +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.core.content.ContextCompat import network.bisq.mobile.presentation.MainPresenter import network.bisq.mobile.presentation.ui.App import org.koin.android.ext.android.inject @@ -12,6 +19,9 @@ import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val presenter : MainPresenter by inject() + // TODO probably better to handle from presenter once the user reach home + private lateinit var requestPermissionLauncher: ActivityResultLauncher + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) presenter.attachView(this) @@ -19,8 +29,9 @@ class MainActivity : ComponentActivity() { setContent { App() } - } + handleDynamicPermissions() + } override fun onStart() { super.onStart() presenter.onStart() @@ -46,6 +57,39 @@ class MainActivity : ComponentActivity() { presenter.onDestroy() super.onDestroy() } + + private fun handleDynamicPermissions() { + requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + // Permission granted, proceed with posting notifications + } else { + // Permission denied, show a message to the user + Toast.makeText(this, "Permission denied. Notifications won't be sent.", Toast.LENGTH_SHORT).show() + } + } + + // Call the method to check and request permission in APIs where its mandatory + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkAndRequestNotificationPermission() + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun checkAndRequestNotificationPermission() { + // Check if the permission is granted + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted, proceed with posting notifications + } else { + // Request permission if not granted + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } + } @Preview diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainApplication.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainApplication.kt index 76262685..29c3eb70 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainApplication.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/MainApplication.kt @@ -8,6 +8,7 @@ import bisq.common.facades.android.AndroidJdkFacade import bisq.common.network.AndroidEmulatorLocalhostFacade import network.bisq.mobile.android.node.di.androidNodeModule import network.bisq.mobile.domain.di.domainModule +import network.bisq.mobile.domain.di.serviceModule import network.bisq.mobile.presentation.di.presentationModule import org.bouncycastle.jce.provider.BouncyCastleProvider import org.koin.android.ext.koin.androidContext @@ -40,7 +41,7 @@ class MainApplication : Application() { startKoin { androidContext(this@MainApplication) // order is important, last one is picked for each interface/class key - modules(listOf(domainModule, presentationModule, androidNodeModule)) + modules(listOf(domainModule, serviceModule, presentationModule, androidNodeModule)) } } } diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/di/AndroidNodeModule.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/di/AndroidNodeModule.kt index 659ed74b..ecaa720c 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/di/AndroidNodeModule.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/di/AndroidNodeModule.kt @@ -41,7 +41,7 @@ val androidNodeModule = module { single { NodeOfferbookServiceFacade(get(), get()) } // this line showcases both, the possibility to change behaviour of the app by changing one definition // and binding the same obj to 2 different abstractions - single { NodeMainPresenter(get(), get(), get(), get(), get()) } bind AppPresenter::class + single { NodeMainPresenter(get(), get(), get(), get(), get(), get()) } bind AppPresenter::class single { OnBoardingNodePresenter(get()) } bind IOnboardingPresenter::class } \ No newline at end of file diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/market_price/NodeMarketPriceServiceFacade.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/market_price/NodeMarketPriceServiceFacade.kt index 6e835dd7..ea1ae5a6 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/market_price/NodeMarketPriceServiceFacade.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/market_price/NodeMarketPriceServiceFacade.kt @@ -55,8 +55,12 @@ class NodeMarketPriceServiceFacade(private val applicationService: AndroidApplic private fun observeSelectedMarket() { selectedMarketPin?.unbind() selectedMarketPin = marketPriceService.selectedMarket.addObserver { market -> - _marketPriceItem.value = MarketPriceItem(toReplicatedMarket(market)) - updatePrice() + try { + _marketPriceItem.value = MarketPriceItem(toReplicatedMarket(market)) + updatePrice() + } catch (e: Exception) { + log.e("Failed to update market item", e) + } } } diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/market/NodeSelectedOfferbookMarketService.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/market/NodeSelectedOfferbookMarketService.kt index f209ee0e..8083b209 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/market/NodeSelectedOfferbookMarketService.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/market/NodeSelectedOfferbookMarketService.kt @@ -73,8 +73,8 @@ class NodeSelectedOfferbookMarketService( private fun observeSelectedChannel() { selectedChannelPin = bisqEasyOfferbookChannelSelectionService.selectedChannel.addObserver { selectedChannel -> - this.selectedChannel = selectedChannel as BisqEasyOfferbookChannel - marketPriceService.setSelectedMarket(selectedChannel.market) + this.selectedChannel = selectedChannel as BisqEasyOfferbookChannel? + marketPriceService.setSelectedMarket(selectedChannel?.market) applySelectedOfferbookMarket() } } diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeMainPresenter.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeMainPresenter.kt index 6e533f61..7a02b699 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeMainPresenter.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/presentation/NodeMainPresenter.kt @@ -4,17 +4,19 @@ import android.app.Activity import network.bisq.mobile.android.node.AndroidApplicationService import network.bisq.mobile.android.node.service.AndroidMemoryReportService import network.bisq.mobile.domain.data.repository.main.bootstrap.ApplicationBootstrapFacade +import network.bisq.mobile.domain.service.controller.NotificationServiceController import network.bisq.mobile.domain.service.market_price.MarketPriceServiceFacade import network.bisq.mobile.domain.service.offerbook.OfferbookServiceFacade import network.bisq.mobile.presentation.MainPresenter class NodeMainPresenter( + notificationServiceController: NotificationServiceController, private val provider: AndroidApplicationService.Provider, private val androidMemoryReportService: AndroidMemoryReportService, private val applicationBootstrapFacade: ApplicationBootstrapFacade, private val offerbookServiceFacade: OfferbookServiceFacade, private val marketPriceServiceFacade: MarketPriceServiceFacade -) : MainPresenter() { +) : MainPresenter(notificationServiceController) { private var applicationServiceCreated = false override fun onViewAttached() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bee6b3ed..00a10d62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ androidx-activityCompose = "1.9.2" androidx-appcompat = "1.7.0" androidx-constraintlayout = "2.1.4" +androidx-core = "1.13.1" androidx-core-ktx = "1.13.1" androidx-espresso-core = "3.6.1" androidx-material = "1.12.0" @@ -92,6 +93,7 @@ androidx-test-compose = { group = "androidx.compose.ui", name = "ui-test-junit4- androidx-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "androidx-test-compose-ver" } #kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +androidx-core = { group = "androidx.core", name = "core", version.ref = "androidx-core" } #androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } roboelectric = { group = "org.robolectric", name = "robolectric", version.ref = "roboelectric" } androidx-test = { group = "androidx.test", name = "core", version.ref = "androidx-test" } diff --git a/iosClient/iosClient/Info.plist b/iosClient/iosClient/Info.plist index 412e3781..cc9f0365 100644 --- a/iosClient/iosClient/Info.plist +++ b/iosClient/iosClient/Info.plist @@ -2,6 +2,12 @@ + BGTaskSchedulerPermittedIdentifiers + + network.bisq.mobile.iosUC4273Y485 + + 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/iosClient/iosClient/iosClient.swift b/iosClient/iosClient/iosClient.swift index f105b707..f7888fc7 100644 --- a/iosClient/iosClient/iosClient.swift +++ b/iosClient/iosClient/iosClient.swift @@ -3,6 +3,7 @@ import presentation @main struct iosClient: App { + init() { DependenciesProviderHelper().doInitKoin() } @@ -12,4 +13,5 @@ struct iosClient: App { ContentView() } } -} + +} \ No newline at end of file diff --git a/shared/domain/build.gradle.kts b/shared/domain/build.gradle.kts index f7ebdcf6..24f63cfe 100644 --- a/shared/domain/build.gradle.kts +++ b/shared/domain/build.gradle.kts @@ -65,6 +65,12 @@ kotlin { implementation(libs.kotlinx.coroutines.test) implementation(libs.koin.test) } + androidMain.dependencies { + implementation(libs.androidx.core) + + implementation(libs.koin.core) + implementation(libs.koin.android) + } androidUnitTest.dependencies { implementation(libs.mock.io) implementation(libs.kotlin.test.junit.v180) diff --git a/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/di/DomainModule.android.kt b/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/di/DomainModule.android.kt new file mode 100644 index 00000000..e70224a9 --- /dev/null +++ b/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/di/DomainModule.android.kt @@ -0,0 +1,9 @@ +package network.bisq.mobile.domain.di + +import network.bisq.mobile.domain.service.controller.NotificationServiceController +import org.koin.dsl.module +import org.koin.android.ext.koin.androidContext + +val serviceModule = module { + single { NotificationServiceController(androidContext()) } +} \ No newline at end of file diff --git a/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/BisqForegroundService.kt b/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/BisqForegroundService.kt new file mode 100644 index 00000000..fee110c9 --- /dev/null +++ b/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/BisqForegroundService.kt @@ -0,0 +1,126 @@ +package network.bisq.mobile.domain.service + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import network.bisq.mobile.utils.Logging + +/** + * Implements foreground service (api >= 26) or background service accordingly + * + * This class is open for extension (for example, for the androidNode) + * + * android docs: https://developer.android.com/develop/background-work/services/foreground-services + */ +open class BisqForegroundService : Service(), Logging { + companion object { + const val CHANNEL_ID = "BISQ_SERVICE_CHANNEL" + const val SERVICE_ID = 21000000 + const val PUSH_NOTIFICATION_ID = 1 + const val SERVICE_NAME = "Bisq Foreground Service" + const val PUSH_NOTIFICATION_ACTION_KEY = "network.bisq.bisqapps.ACTION_REQUEST_PERMISSION" + } + + private lateinit var silentNotification: Notification + + private lateinit var defaultNotification: Notification + + override fun onCreate() { + super.onCreate() + initDefaultNotifications() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceCompat.startForeground(this, SERVICE_ID, silentNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING) + log.i { "Started as foreground service compat"} + } else { + startForeground(SERVICE_ID, silentNotification) + log.i { "Started foreground"} + } + log.i { "Service ready" } + + CoroutineScope(Dispatchers.Main).launch { + delay(10000) // Wait for 10 seconds + // Create an Intent to open the MainActivity when the notification is tapped + val pendingIntent = null +// val intent = Intent(this, MainActivity::class.java).apply { +// flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK // Clears any existing task and starts a new one +// } +// +// // Create a PendingIntent to wrap the Intent +// val pendingIntent: PendingIntent = PendingIntent.getActivity( +// this@BisqForegroundService, +// 0, +// intent, +// PendingIntent.FLAG_UPDATE_CURRENT // This flag updates the existing PendingIntent if it's already created +// ) + val updatedNotification: Notification = NotificationCompat.Builder(this@BisqForegroundService, CHANNEL_ID) + .setContentTitle("New Update!") + .setContentText("Tap to open the app") + .setSmallIcon(android.R.drawable.ic_notification_overlay) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setOngoing(true) // Keeps the notification active + .setContentIntent(pendingIntent) // Set the pending intent to launch the app + .build() + + // Update the notification +// NotificationManagerCompat.from(this@BisqForegroundService).notify(SERVICE_ID, updatedNotification) + + val notificationManager = this@BisqForegroundService.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(PUSH_NOTIFICATION_ID, updatedNotification) + + // Log the update + log.i { "Notification updated after 10 seconds" } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Check if notification permission is granted + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED) { + // Send a broadcast to the activity to request permission + val broadcastIntent = Intent(PUSH_NOTIFICATION_ACTION_KEY) + sendBroadcast(broadcastIntent) + } + log.i { "Service starting sticky" } + return START_STICKY + } + + override fun onDestroy() { + log.i { "Service is being destroyed" } + super.onDestroy() + // Cleanup tasks + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun initDefaultNotifications() { + silentNotification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("") // No title + .setContentText("") // No content + .setSmallIcon(android.R.drawable.ic_notification_overlay) + .setPriority(NotificationCompat.PRIORITY_MIN) // Silent notification + .setOngoing(true) // Keeps the notification active + .build() + defaultNotification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(SERVICE_NAME) + .setSmallIcon(android.R.drawable.ic_notification_overlay) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // For android previous to O + .build() + } +} \ No newline at end of file diff --git a/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.android.kt b/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.android.kt new file mode 100644 index 00000000..17e65b3d --- /dev/null +++ b/shared/domain/src/androidMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.android.kt @@ -0,0 +1,83 @@ +package network.bisq.mobile.domain.service.controller + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import network.bisq.mobile.domain.service.BisqForegroundService +import network.bisq.mobile.utils.Logging + +/** + * Controller interacting with the bisq service + */ +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class NotificationServiceController (private val context: Context): ServiceController, Logging { + + companion object { + const val SERVICE_NAME = "Bisq Service" + } + private var isRunning = false + + /** + * Starts the service in the appropiate mode based on the current device running Android API + */ + actual override fun startService() { + if (!isRunning) { + log.i { "Starting Bisq Service.."} + createNotificationChannel() + val intent = Intent(context, BisqForegroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + log.i { "OS supports foreground service" } + context.startForegroundService(intent) + } else { + // if the phone does not support foreground service + context.startService(intent) + } + isRunning = true + log.i { "Started Bisq Service"} + } + } + + // TODO provide an access for this + actual override fun stopService() { + // TODO we need to leave the service running if the user is ok with it + if (isRunning) { + deleteNotificationChannel() + val intent = Intent(context, BisqForegroundService::class.java) + context.stopService(intent) + isRunning = false + } + } + + // TODO support for on click + actual fun pushNotification(title: String, message: String) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = NotificationCompat.Builder(context, BisqForegroundService.CHANNEL_ID) + .setContentTitle(title) + .setContentText(message) + .setSmallIcon(android.R.drawable.ic_notification_overlay) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // For android previous to O + .setOngoing(true) + .build() + notificationManager.notify(BisqForegroundService.PUSH_NOTIFICATION_ID, notification) + } + + actual override fun isServiceRunning() = isRunning + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(BisqForegroundService.CHANNEL_ID, SERVICE_NAME, NotificationManager.IMPORTANCE_LOW) + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + private fun deleteNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.deleteNotificationChannel(BisqForegroundService.CHANNEL_ID) + } + } +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt index 529933bc..39404041 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt @@ -58,7 +58,7 @@ class ClientMarketPriceServiceFacade( applyQuote() } catch (e: Exception) { - log.e("Error at API request", e) + log.e("Error at getQuotes API request", e) } } } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt index f1d6c9b2..42fb9687 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt @@ -24,7 +24,7 @@ class ClientMarketListItemService(private val apiGateway: OfferbookApiGateway) : // Misc private val coroutineScope = CoroutineScope(BackgroundDispatcher) private var job: Job? = null - private var polling = Polling(1000) { updateNumOffers() } + private var polling = Polling(10000) { updateNumOffers() } private var marketListItemsRequested = false // Life cycle @@ -45,7 +45,7 @@ class ClientMarketListItemService(private val apiGateway: OfferbookApiGateway) : requestAndApplyNumOffers() marketListItemsRequested = true } catch (e: Exception) { - log.e("Error at API request", e) + log.e("Error at Fill Market List Items API request", e) } } } @@ -87,7 +87,7 @@ class ClientMarketListItemService(private val apiGateway: OfferbookApiGateway) : marketListItem } } catch (e: Exception) { - log.e("Error at API request", e) + log.e("Error at apply num offers for markets API request", e) } } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt index c6a0aa8a..0b839267 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt @@ -24,7 +24,7 @@ class ClientOfferbookListItemService(private val apiGateway: OfferbookApiGateway // Misc private var job: Job? = null - private var polling = Polling(1000) { updateOffers() } + private var polling = Polling(10000) { updateOffers() } private var selectedMarket: MarketListItem? = null private val coroutineScope = CoroutineScope(BackgroundDispatcher) diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/di/DomainModule.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/di/DomainModule.kt index c62ad71d..18de796e 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/di/DomainModule.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/di/DomainModule.kt @@ -7,6 +7,10 @@ import network.bisq.mobile.domain.data.model.Greeting import network.bisq.mobile.domain.data.persistance.KeyValueStorage import network.bisq.mobile.domain.data.repository.* import network.bisq.mobile.domain.getPlatformSettings +import network.bisq.mobile.domain.data.repository.BisqStatsRepository +import network.bisq.mobile.domain.data.repository.BtcPriceRepository +import network.bisq.mobile.domain.data.repository.GreetingRepository +import network.bisq.mobile.domain.data.repository.SettingsRepository import org.koin.dsl.module val domainModule = module { diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.kt new file mode 100644 index 00000000..a1173823 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.kt @@ -0,0 +1,12 @@ +package network.bisq.mobile.domain.service.controller + +/** + * And interface for a controller of a notification service + */ +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +expect class NotificationServiceController: ServiceController { + fun pushNotification(title: String, message: String) + override fun startService() + override fun stopService() + override fun isServiceRunning(): Boolean +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/ServiceController.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/ServiceController.kt new file mode 100644 index 00000000..1ab1da4b --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/controller/ServiceController.kt @@ -0,0 +1,10 @@ +package network.bisq.mobile.domain.service.controller + +/** + * Service controller behaviour definitions + */ +interface ServiceController { + fun startService() + fun stopService() + fun isServiceRunning(): Boolean +} \ No newline at end of file diff --git a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt index bf9d5b98..3cc9310b 100644 --- a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt +++ b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt @@ -1,10 +1,12 @@ package network.bisq.mobile.domain.di +import network.bisq.mobile.domain.service.controller.NotificationServiceController import org.koin.core.qualifier.named import org.koin.dsl.module val iosDomainModule = module { single(named("ApiBaseUrl")) { provideApiBaseUrl() } + single { NotificationServiceController() } } fun provideApiBaseUrl(): String { 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 new file mode 100644 index 00000000..d8eddada --- /dev/null +++ b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/service/controller/NotificationServiceController.ios.kt @@ -0,0 +1,169 @@ +package network.bisq.mobile.domain.service.controller + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import network.bisq.mobile.utils.Logging +import platform.BackgroundTasks.* +import platform.Foundation.NSDate +import platform.Foundation.NSUUID +import platform.Foundation.setValue +import platform.UserNotifications.* +import platform.darwin.NSObject + + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class NotificationServiceController: ServiceController, Logging { + + companion object { + const val BACKGROUND_TASK_ID = "network.bisq.mobile.iosUC4273Y485" + const val CHECK_NOTIFICATIONS_DELAY = 15 * 10000L + } + + private var isRunning = false + private var isBackgroundTaskRegistered = false + private val logScope = CoroutineScope(Dispatchers.Main) + + private fun setupDelegate() { + val delegate = object : NSObject(), UNUserNotificationCenterDelegateProtocol { + override fun userNotificationCenter( + center: UNUserNotificationCenter, + willPresentNotification: UNNotification, + withCompletionHandler: (UNNotificationPresentationOptions) -> Unit + ) { + // Display alert, sound, or badge when the app is in the foreground + withCompletionHandler( + UNNotificationPresentationOptionAlert or UNNotificationPresentationOptionSound or UNNotificationPresentationOptionBadge + ) + } + + // Handle user actions on the notification + override fun userNotificationCenter( + center: UNUserNotificationCenter, + didReceiveNotificationResponse: UNNotificationResponse, + withCompletionHandler: () -> Unit + ) { + // Handle the response when the user taps the notification + withCompletionHandler() + } + } + + UNUserNotificationCenter.currentNotificationCenter().delegate = delegate + logDebug("Notification center delegate applied") + } + + + 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.") + setupDelegate() + registerBackgroundTask() + // Once permission is granted, you can start scheduling background tasks + startBackgroundTaskLoop() + logDebug("Background service started") + isRunning = true + } else { + logDebug("Notification permission denied: ${error?.localizedDescription}") + } + } + } + + 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") + setSound(UNNotificationSound.defaultSound()) + } + + val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(5.0, repeats = false) + + val requestId = NSUUID().UUIDString + val request = UNNotificationRequest.requestWithIdentifier( + requestId, + content, + trigger + ) + UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(request) { error -> + if (error != null) { + logDebug("Error adding notification request: ${error.localizedDescription}") + } else { + logDebug("Notification $requestId added successfully") + } + } + } + + actual override fun isServiceRunning(): Boolean { + // iOS doesn't allow querying background task state directly + return isRunning + } + + private fun handleBackgroundTask(task: BGProcessingTask) { + task.setTaskCompletedWithSuccess(true) // Mark the task as completed + logDebug("Background task completed successfully") + scheduleBackgroundTask() // Re-schedule the next task + } + + private fun startBackgroundTaskLoop() { + CoroutineScope(Dispatchers.Default).launch { + while (isRunning) { + scheduleBackgroundTask() + delay(CHECK_NOTIFICATIONS_DELAY) // Check notifications every min + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun scheduleBackgroundTask() { + val request = BGProcessingTaskRequest(BACKGROUND_TASK_ID).apply { + requiresNetworkConnectivity = true + earliestBeginDate = NSDate(timeIntervalSinceReferenceDate = 10.0) + } + BGTaskScheduler.sharedScheduler.submitTaskRequest(request, null) + logDebug("Background task scheduled") + } + +// fun setupTaskHandlers() { +// BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier(BACKGROUND_TASK_ID, BGProcessingTask.class, ::handleBackgroundTask) +// } + + + private fun logDebug(message: String) { + logScope.launch { + log.d { message } + } + } + + + private fun registerBackgroundTask() { + if (isBackgroundTaskRegistered) { + logDebug("Background task is already registered.") + return + } + + // Register for background task handler + BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier( + identifier = BACKGROUND_TASK_ID, + usingQueue = null + ) { task -> + handleBackgroundTask(task as BGProcessingTask) + } + + isBackgroundTaskRegistered = true + logDebug("Background task handler registered.") + } +} diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt index 897a443c..c13f2ea2 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt @@ -1,15 +1,17 @@ package network.bisq.mobile.client import network.bisq.mobile.domain.data.repository.main.bootstrap.ApplicationBootstrapFacade +import network.bisq.mobile.domain.service.controller.NotificationServiceController import network.bisq.mobile.domain.service.market_price.MarketPriceServiceFacade import network.bisq.mobile.domain.service.offerbook.OfferbookServiceFacade import network.bisq.mobile.presentation.MainPresenter class ClientMainPresenter( + notificationServiceController: NotificationServiceController, private val applicationBootstrapFacade: ApplicationBootstrapFacade, private val offerbookServiceFacade: OfferbookServiceFacade, private val marketPriceServiceFacade: MarketPriceServiceFacade -) : MainPresenter() { +) : MainPresenter(notificationServiceController) { override fun onViewAttached() { super.onViewAttached() 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 7df17d48..a91d01a7 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,19 +1,33 @@ 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 kotlinx.coroutines.launch import network.bisq.mobile.android.node.BuildNodeConfig import network.bisq.mobile.client.shared.BuildConfig +import network.bisq.mobile.domain.data.BackgroundDispatcher 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 /** * Main Presenter as an example of implementation for now. */ -open class MainPresenter() : +open class MainPresenter(private val notificationServiceController: NotificationServiceController) : BasePresenter(null), AppPresenter { + companion object { + // FIXME this will be erased eventually, for now you can turn on to see the notifications working + // it will push a notification every 60 sec + const val testNotifications = false + const val PUSH_DELAY = 60000L + } + lateinit var navController: NavHostController private set @@ -39,9 +53,24 @@ open class MainPresenter() : 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}" } - // CoroutineScope(BackgroundDispatcher).launch { - // greetingRepository.create(Greeting()) - // } + } + + @CallSuper + override fun onViewAttached() { + super.onViewAttached() + notificationServiceController.startService() + // sample code for push notifications sends a random message every 10 secs + if (testNotifications) { + backgroundScope.launch { + while (notificationServiceController.isServiceRunning()) { + val randomTitle = "Title ${Random.nextInt(1, 100)}" + val randomMessage = "Message ${Random.nextInt(1, 100)}" + notificationServiceController.pushNotification(randomTitle, randomMessage) + log.d {"Pushed: $randomTitle - $randomMessage" } + delay(PUSH_DELAY) // 1 min + } + } + } } // Toggle action diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt index 66662bd6..475f46a8 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt @@ -31,7 +31,7 @@ val presentationModule = module { single(named("RootNavController")) { getKoin().getProperty("RootNavController") } single(named("TabNavController")) { getKoin().getProperty("TabNavController") } - single { ClientMainPresenter(get(), get(), get()) } bind AppPresenter::class + single { ClientMainPresenter(get(), get(), get(), get()) } bind AppPresenter::class single { TopBarPresenter(get(), get()) } bind ITopBarPresenter::class 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 diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt index ebc6b832..a2b2b583 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt @@ -12,7 +12,6 @@ import network.bisq.mobile.domain.data.repository.BisqStatsRepository import network.bisq.mobile.domain.data.repository.BtcPriceRepository import network.bisq.mobile.presentation.BasePresenter import network.bisq.mobile.presentation.MainPresenter -import network.bisq.mobile.presentation.ui.navigation.Routes class GettingStartedPresenter( mainPresenter: MainPresenter, diff --git a/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt b/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt index 66bd3a59..3b8a2427 100644 --- a/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt +++ b/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt @@ -11,6 +11,9 @@ import org.koin.core.context.startKoin import org.koin.core.parameter.parametersOf import org.koin.core.qualifier.Qualifier +/** + * Helper for iOS koin injection + */ class DependenciesProviderHelper { fun initKoin() {