Skip to content

Commit f587c23

Browse files
Copilotpelcm_pegamarekpelc-pega
authored
BUG-997064: handle lifecycle of ConstellationSdkEngine (#21)
* feat: add destroy/pause/resume lifecycle methods to ConstellationSdkEngine and AndroidWebViewEngine Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/7e70209c-4f08-426e-b3a9-0c454e221ba2 Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * fix: guard against uninitialized engine in onDestroy of sample activities Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/7e70209c-4f08-426e-b3a9-0c454e221ba2 Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * feat(ios): implement destroy() in WKWebViewBasedEngine Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/097a321d-5a23-42da-9569-ded113b05c01 Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * fix: cancel coroutine scope last in destroy(), use Log.i consistently Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/097a321d-5a23-42da-9569-ded113b05c01 Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * docs: add KDoc to AndroidWebViewEngine with Activity context requirement note Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/fc3b89c7-2f95-4d94-b331-ab643f8a573d Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * refactor(ios): extract script message handler names into constants Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/fc3b89c7-2f95-4d94-b331-ab643f8a573d Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * BUG-997064: fixing AndroidWebViewEngine class kdoc * feat: wire engine lifecycle (pause/resume/destroy) in Android and iOS samples Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/9ced4a42-90de-4c61-b4ae-b07194fb6fd5 Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * docs/fix: address code review feedback on KDoc wording and iOS destroy safety Agent-Logs-Url: https://github.com/Pega-ProductEngineering/constellation-mobile-sdk/sessions/9ced4a42-90de-4c61-b4ae-b07194fb6fd5 Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> * BUG-997064: making webview nullable, removing isInitialized check on engine * BUG-997064: fixing re-tries in compose tests * BUG-997064: adding android tests for engine destroy pause resume * BUG-997064: trying to fix ios failing on pipeline * BUG-997064: enforcing engine destroy/pause/resume to be called on the main thread for android * BUG-997064: review fixes * BUG-997064: fixing test - test_engine_pause_resume * BUG-997064: enhancing cleanup in ios destroy * BUG-997064: fixing destroy in WKWebViewBasedEngine.kt --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelcm_pega <203986693+pelcm_pega@users.noreply.github.com> Co-authored-by: pelcm <marek.pelc@pega.com>
1 parent cf450d0 commit f587c23

14 files changed

Lines changed: 247 additions & 65 deletions

File tree

.github/workflows/checks.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ jobs:
111111
run: |
112112
set -o pipefail
113113
rm -rf build || true
114-
mkdir build
114+
mkdir -p build
115115
xcodebuild \
116116
-project samples/swiftui-components-app/UITest/UITest.xcodeproj \
117117
-scheme UITest \
@@ -120,7 +120,8 @@ jobs:
120120
-retry-tests-on-failure \
121121
-test-iterations 2 \
122122
-parallel-testing-enabled NO \
123-
test 2>&1 | xcpretty && exit ${PIPESTATUS[0]}
123+
test 2>&1 | xcpretty
124+
exit ${PIPESTATUS[0]}
124125
- name: Upload test artifacts for failures
125126
if: failure()
126127
uses: actions/upload-artifact@v4

core/src/commonMain/kotlin/com/pega/constellation/sdk/kmp/core/ConstellationSdkEngine.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ interface ConstellationSdkEngine {
1515
* Performs the specified action using the Constellation SDK.
1616
*/
1717
fun performAction(action: ConstellationSdkAction)
18+
19+
/**
20+
* Releases all resources held by the engine. Must be called when the engine is no longer needed.
21+
*/
22+
fun destroy()
23+
1824
}
1925

2026
/**

engine-mock/src/commonMain/kotlin/com/pega/constellation/sdk/kmp/engine/mock/MockSdkEngine.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ class MockSdkEngine : ConstellationSdkEngine {
4040
handler.handle(EngineEvent.Ready(EnvironmentInfo("en-US", "America/New_York")))
4141
}
4242

43+
override fun destroy() {
44+
// No cleanup needed for mock engine
45+
}
46+
4347
private fun ComponentManager.configureComponents() {
4448
component(10, ComponentTypes.TextInput) {
4549
put("label", "First Name")

engine-webview/src/androidMain/kotlin/com/pega/constellation/sdk/kmp/engine/webview/android/AndroidWebViewEngine.kt

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.pega.constellation.sdk.kmp.engine.webview.android.internal.SdkWebView
3636
import com.pega.constellation.sdk.kmp.engine.webview.common.EngineConfiguration
3737
import com.pega.constellation.sdk.kmp.engine.webview.common.InternalError
3838
import com.pega.constellation.sdk.kmp.engine.webview.common.JsError
39+
import androidx.annotation.MainThread
3940
import kotlinx.coroutines.CoroutineScope
4041
import kotlinx.coroutines.Dispatchers
4142
import kotlinx.coroutines.launch
@@ -44,6 +45,23 @@ import okhttp3.OkHttpClient
4445
import org.json.JSONObject
4546
import java.util.concurrent.TimeUnit
4647

48+
/**
49+
* WebView-based implementation of [ConstellationSdkEngine] for Android.
50+
*
51+
* Embeds a [WebView] hosting the Constellation CoreJS engine with JS components.
52+
* Network requests are intercepted and routed through the provided [OkHttpClient] instances.
53+
*
54+
* @param context The Android [android.content.Context] used to create the [WebView].
55+
* An Activity context (or a context wrapping an Activity) is recommended.
56+
* While we haven't encountered any issues using an Application context,
57+
* the official Android documentation states it may cause WebView to behave incorrectly.
58+
* (https://developer.android.com/reference/android/webkit/WebView#WebView(android.content.Context)
59+
* @param scope [CoroutineScope] used to dispatch async work. The engine does not cancel this
60+
* scope on [destroy], prefer lifecycle-aware scopes such as `lifecycleScope` or
61+
* `viewModelScope` that are canceled automatically when the host is destroyed.
62+
* @param okHttpClient Primary client for Constellation DX API requests.
63+
* @param nonDxOkHttpClient Client for non-DX requests. Defaults to [Companion.defaultHttpClient].
64+
*/
4765
class AndroidWebViewEngine(
4866
private val context: Context,
4967
private val scope: CoroutineScope,
@@ -56,7 +74,7 @@ class AndroidWebViewEngine(
5674
private lateinit var componentManager: ComponentManager
5775
private lateinit var networkInterceptor: WebViewNetworkInterceptor
5876
private lateinit var webViewClient: SdkWebViewClient
59-
private lateinit var webView: WebView
77+
private var webView: WebView? = null
6078

6179
override fun configure(config: ConstellationSdkConfig, handler: EngineEventHandler) {
6280
this.config = config
@@ -76,9 +94,58 @@ class AndroidWebViewEngine(
7694
override fun performAction(action: ConstellationSdkAction) {
7795
handler.handle(EngineEvent.Loading)
7896
webViewClient.onPageLoad = { onPageLoad(action) }
97+
val webView = requireNotNull(webView) { WEBVIEW_NULL_MESSAGE }
7998
webView.loadUrl(config.pegaUrl)
8099
}
81100

101+
/**
102+
* Releases all resources held by the engine.
103+
*
104+
* Must be called from the **main thread** — from `Activity.onDestroy()`,
105+
* a Compose `DisposableEffect`, or `ViewModel.onCleared()` (via `Dispatchers.Main`).
106+
*/
107+
@MainThread
108+
override fun destroy() {
109+
val wv = webView ?: return
110+
Log.d(TAG, "Destroying WebView")
111+
with(wv) {
112+
stopLoading()
113+
removeJavascriptInterface("sdkbridge")
114+
webViewClient = WebViewClient()
115+
webChromeClient = null
116+
destroy()
117+
}
118+
webView = null
119+
}
120+
121+
/**
122+
* Pauses engine processing.
123+
*
124+
* Should be called when the host view is paused.
125+
* Must be called from the **main thread** — from `Activity.onPause()`.
126+
*/
127+
@MainThread
128+
fun pause() {
129+
val wv = webView ?: return
130+
Log.d(TAG, "Pausing WebView")
131+
wv.onPause()
132+
wv.pauseTimers()
133+
}
134+
135+
/**
136+
* Resumes engine processing after a previous [pause] call.
137+
*
138+
* Should be called when the host view is resumed.
139+
* Must be called from the **main thread** — from `Activity.onResume()`.
140+
*/
141+
@MainThread
142+
fun resume() {
143+
val wv = webView ?: return
144+
Log.d(TAG, "Resuming WebView")
145+
wv.onResume()
146+
wv.resumeTimers()
147+
}
148+
82149
private fun onPageLoad(action: ConstellationSdkAction) {
83150
val configuration = EngineConfiguration(
84151
url = config.pegaUrl,
@@ -96,6 +163,7 @@ class AndroidWebViewEngine(
96163
}
97164

98165
private fun evaluateInit(sdkConfig: String, scripts: String) {
166+
val webView = requireNotNull(webView) { WEBVIEW_NULL_MESSAGE }
99167
webView.evaluateJavascript("typeof window.init") { result ->
100168
if (result == "\"function\"") {
101169
webView.evaluateJavascript("window.init('$sdkConfig', '$scripts')", null)
@@ -136,6 +204,7 @@ class AndroidWebViewEngine(
136204
put("eventData", JSONObject(event.eventData))
137205
}
138206
val script = "window.sendEventToComponent('${id.id}', '$eventJson')"
207+
val webView = requireNotNull(webView) { WEBVIEW_NULL_MESSAGE }
139208
this.scope.launch(Dispatchers.Main.immediate) {
140209
webView.evaluateJavascript(script, null)
141210
}
@@ -186,6 +255,7 @@ class AndroidWebViewEngine(
186255

187256
companion object Companion {
188257
private const val TAG = "AndroidWebViewEngine"
258+
private const val WEBVIEW_NULL_MESSAGE = "WebView is null, probably has been destroyed."
189259

190260
fun defaultHttpClient() = OkHttpClient.Builder()
191261
.connectTimeout(30, TimeUnit.SECONDS)

engine-webview/src/iosMain/kotlin/com/pega/constellation/sdk/kmp/engine/webview/ios/FormHandler.kt

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import platform.WebKit.*
1717
import platform.darwin.NSObject
1818

1919
private const val TAG = "FormHandler"
20-
class FormHandler() : NSObject(), WKScriptMessageHandlerProtocol {
21-
lateinit var eventHandler: EngineEventHandler
22-
lateinit var componentManager: ComponentManager
20+
class FormHandler : NSObject(), WKScriptMessageHandlerProtocol {
21+
var eventHandler: EngineEventHandler? = null
22+
var componentManager: ComponentManager? = null
2323
private val passthroughSubject = MutableSharedFlow<ComponentEvent>(extraBufferCapacity = 64)
2424
val eventStream: SharedFlow<ComponentEvent> = passthroughSubject
2525

@@ -35,6 +35,7 @@ class FormHandler() : NSObject(), WKScriptMessageHandlerProtocol {
3535
Log.w(TAG, "Cannot decode message type")
3636
return
3737
}
38+
val eventHandler = requireNotNull(eventHandler)
3839
when (type) {
3940
"updateComponent" -> handleUpdateComponent(array)
4041
"addComponent" -> handleAddComponent(array)
@@ -52,15 +53,15 @@ class FormHandler() : NSObject(), WKScriptMessageHandlerProtocol {
5253
}
5354

5455
fun handleLoading() {
55-
eventHandler.handle(EngineEvent.Loading)
56+
requireNotNull(eventHandler).handle(EngineEvent.Loading)
5657
}
5758

5859
private fun handleUpdateComponent(input: List<Any?>) {
5960
val cId = input.componentId
6061
val props = input.getOrNull(2) as? String
6162
if (cId != null && props != null) {
6263
val propsJson = Json.parseToJsonElement(props).jsonObject
63-
componentManager.updateComponent(ComponentId(cId), propsJson)
64+
requireNotNull(componentManager).updateComponent(ComponentId(cId), propsJson)
6465
} else {
6566
Log.w(TAG, "Unexpected parameters types in updateComponent")
6667
}
@@ -73,7 +74,7 @@ class FormHandler() : NSObject(), WKScriptMessageHandlerProtocol {
7374
Log.w(TAG, "Unexpected input for addComponent.")
7475
return
7576
}
76-
77+
val componentManager = requireNotNull(componentManager)
7778
val context = WKWebViewEngineComponentContext(
7879
ComponentId(cId),
7980
ComponentType(cType),
@@ -89,13 +90,13 @@ class FormHandler() : NSObject(), WKScriptMessageHandlerProtocol {
8990
Log.w(TAG, "Unexpected input for removeComponent.")
9091
return
9192
}
92-
componentManager.removeComponent(ComponentId(cId))
93+
requireNotNull(componentManager).removeComponent(ComponentId(cId))
9394
}
9495

9596
private fun handleOnReady(input: List<Any?>) {
9697
(input.getOrNull(1) as? String)?.let {
9798
val envInfoJson = Json.parseToJsonElement(it).jsonObject
98-
eventHandler.handle(EngineEvent.Ready(envInfoJson.toEnvironmentInfo()))
99+
requireNotNull(eventHandler).handle(EngineEvent.Ready(envInfoJson.toEnvironmentInfo()))
99100
} ?: Log.w(TAG, "Unexpected input for onReady")
100101
}
101102
}

engine-webview/src/iosMain/kotlin/com/pega/constellation/sdk/kmp/engine/webview/ios/ResourceHandler.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ interface ResourceHandlerDelegate {
2828
class ResourceHandler(
2929
val mainScope: () -> CoroutineScope
3030
) : NSObject(), WKURLSchemeHandlerProtocol {
31-
lateinit var delegate: ResourceHandlerDelegate
31+
var delegate: ResourceHandlerDelegate? = null
3232
private val tasks = mutableMapOf<NSURLRequest, Job>()
3333

3434
@ObjCSignatureOverride
@@ -40,7 +40,7 @@ class ResourceHandler(
4040
try {
4141
Log.i(TAG, "Starting WKURLScheme task. <${startURLSchemeTask.request.URL}>")
4242
val (data, response) = withContext(Dispatchers.IO) {
43-
delegate.performRequest(startURLSchemeTask.request)
43+
requireNotNull(delegate).performRequest(startURLSchemeTask.request)
4444
}
4545
if (!isActive) {
4646
Log.i(TAG, "WKURLScheme task cancelled. <${startURLSchemeTask.request.URL}>")

engine-webview/src/iosMain/kotlin/com/pega/constellation/sdk/kmp/engine/webview/ios/WKWebViewBasedEngine.kt

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ class WKWebViewBasedEngine(
6666

6767
wkConfig.userContentController.addScriptMessageHandler(
6868
ConsoleScriptMessageHandler(ConsoleHandler(showDebugLogs = true)),
69-
name = "consoleHandler"
69+
name = CONSOLE_HANDLER_NAME
7070
)
71-
wkConfig.userContentController.addScriptMessageHandler(formHandler, name = "formHandler")
71+
wkConfig.userContentController.addScriptMessageHandler(formHandler, name = FORM_HANDLER_NAME)
7272
webView = WKWebView(frame = CGRectZero.readValue(), wkConfig)
7373
}
7474

@@ -98,7 +98,7 @@ class WKWebViewBasedEngine(
9898
)
9999

100100
val customAndOverriddenComponents =
101-
formHandler.componentManager.getCustomComponentDefinitions()
101+
requireNotNull(formHandler.componentManager).getCustomComponentDefinitions()
102102
.mapNotNull { definition ->
103103
definition.script?.assetPath()?.let { path ->
104104
definition.type.type to path
@@ -251,9 +251,30 @@ class WKWebViewBasedEngine(
251251
webView.navigationDelegate = navigationDelegate
252252
}
253253

254+
override fun destroy() {
255+
Log.i(TAG, "Destroying WKWebView engine.")
256+
mainScope?.cancel()
257+
webView.stopLoading()
258+
webView.configuration.userContentController.runCatching {
259+
removeScriptMessageHandlerForName(CONSOLE_HANDLER_NAME)
260+
removeScriptMessageHandlerForName(FORM_HANDLER_NAME)
261+
}
262+
webView.UIDelegate = null
263+
webView.navigationDelegate = null
264+
uiDelegate = null
265+
navigationDelegate = null
266+
resourceHandler.delegate = null
267+
formHandler.eventHandler = null
268+
formHandler.componentManager = null
269+
initScript = null
270+
initialNavigation = null
271+
}
272+
254273
companion object {
255274
private const val TAG = "WKWebViewBasedEngine"
256275
const val COMPONENT_ASSETS_PREFIX = "/constellation-mobile-sdk-assets/components/"
276+
private const val CONSOLE_HANDLER_NAME = "consoleHandler"
277+
private const val FORM_HANDLER_NAME = "formHandler"
257278
private var tweaksApplied = false
258279
}
259280
}

samples/android-cmp-app/src/androidInstrumentedTest/kotlin/com/pega/constellation/sdk/kmp/samples/androidcmpapp/test/RunAndroidTest.kt

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@ import java.util.Locale
1212
@OptIn(ExperimentalTestApi::class)
1313
fun ComposeTest.runAndroidTest(block: suspend ComposeUiTest.() -> Unit) {
1414
for (attempt in 1..2) {
15-
runComposeUiTest {
16-
runCatching {
17-
block()
18-
}.onFailure {
19-
saveScreenshot(this@runAndroidTest.testName.methodName)
20-
}.also {
21-
if (it.isSuccess) return@runComposeUiTest
22-
if (attempt == 2) it.getOrThrow()
15+
runCatching {
16+
runComposeUiTest {
17+
runCatching {
18+
block()
19+
}.onFailure {
20+
saveScreenshot(this@runAndroidTest.testName.methodName)
21+
}.getOrThrow()
2322
}
23+
}.also {
24+
if (it.isSuccess) break
25+
if (attempt == 2) it.getOrThrow()
2426
}
2527
}
2628
}

samples/android-cmp-app/src/androidMain/kotlin/com/pega/constellation/sdk/kmp/samples/androidcmpapp/MediaCoActivity.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import org.publicvalue.multiplatform.oidc.tokenstore.AndroidSettingsTokenStore
2020

2121
class MediaCoActivity : ComponentActivity() {
2222

23+
private lateinit var engine: AndroidWebViewEngine
24+
2325
@OptIn(ExperimentalOpenIdConnect::class)
2426
override fun onCreate(savedInstanceState: Bundle?) {
2527
super.onCreate(savedInstanceState)
@@ -28,7 +30,7 @@ class MediaCoActivity : ComponentActivity() {
2830
val authFlowFactory = AndroidCodeAuthFlowFactory().also { it.registerActivity(this) }
2931
val authManager = createAuthManager(authFlowFactory)
3032

31-
val engine = AndroidWebViewEngine(
33+
engine = AndroidWebViewEngine(
3234
context = this,
3335
scope = this.lifecycleScope,
3436
okHttpClient = buildHttpClient(authManager)
@@ -41,6 +43,21 @@ class MediaCoActivity : ComponentActivity() {
4143
}
4244
}
4345

46+
override fun onPause() {
47+
engine.pause()
48+
super.onPause()
49+
}
50+
51+
override fun onResume() {
52+
super.onResume()
53+
engine.resume()
54+
}
55+
56+
override fun onDestroy() {
57+
engine.destroy()
58+
super.onDestroy()
59+
}
60+
4461
private fun buildHttpClient(authManager: AuthManager) =
4562
AndroidWebViewEngine.defaultHttpClient()
4663
.newBuilder()

0 commit comments

Comments
 (0)