From a58fb07dfadf56f69d187dca1ced52477b394ab9 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:20:04 -0500 Subject: [PATCH 01/12] feat: add live update provider support --- IonicPortals/build.gradle.kts | 1 + .../main/kotlin/io/ionic/portals/Portal.kt | 117 +++++++++++++++--- .../kotlin/io/ionic/portals/PortalFragment.kt | 11 +- .../kotlin/io/ionic/portals/PortalManager.kt | 27 +--- .../kotlin/io/ionic/portals/PortalView.kt | 4 +- 5 files changed, 110 insertions(+), 50 deletions(-) diff --git a/IonicPortals/build.gradle.kts b/IonicPortals/build.gradle.kts index 37c5207..8364e1c 100644 --- a/IonicPortals/build.gradle.kts +++ b/IonicPortals/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(kotlin("reflect")) api("com.capacitorjs:core:[8.0.0,9.0.0)") + api("io.ionic:liveupdateprovider:0.1.0") compileOnly("io.ionic:liveupdates:0.5.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index f5f7abe..4a04416 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -1,9 +1,15 @@ package io.ionic.portals import android.content.Context +import android.util.Log import com.getcapacitor.Plugin +import io.ionic.liveupdateprovider.LiveUpdateProviderError +import io.ionic.liveupdateprovider.LiveUpdateProviderManager +import io.ionic.liveupdateprovider.LiveUpdateProviderSyncCallback +import io.ionic.liveupdateprovider.LiveUpdateProviderSyncResult import io.ionic.liveupdates.LiveUpdate import io.ionic.liveupdates.LiveUpdateManager +import java.io.File /** * A class representing a Portal that contains information about the web content to load and any @@ -25,6 +31,21 @@ import io.ionic.liveupdates.LiveUpdateManager * @property name the name of the Portal */ class Portal(val name: String) { + /** + * The live update source for a [Portal]. + */ + sealed class LiveUpdateSource { + /** + * Uses Ionic Live Updates to sync and locate the latest web application assets. + */ + data class Ionic(val liveUpdateConfig: LiveUpdate) : LiveUpdateSource() + + /** + * Uses a [LiveUpdateProviderManager] to sync and locate the latest web application assets. + */ + data class Provider(val manager: LiveUpdateProviderManager) : LiveUpdateSource() + } + /** * Capacitor [Plugin] registered with the Portal. */ @@ -73,22 +94,30 @@ class Portal(val name: String) { var devMode: Boolean = true /** - * A LiveUpdate config, if live updates is being used. + * The live update source for this Portal. + * + * Use [LiveUpdateSource.Ionic] for Ionic Live Updates, or [LiveUpdateSource.Provider] for + * an external provider built with the Live Update Provider SDK. */ - var liveUpdateConfig: LiveUpdate? = null + var liveUpdateSource: LiveUpdateSource? = null set(value) { field = value - if (value != null) { - if(value.assetPath == null) { - value.assetPath = this.startDir - } + if (value is LiveUpdateSource.Ionic && value.liveUpdateConfig.assetPath == null) { + value.liveUpdateConfig.assetPath = this.startDir } } /** - * Whether to run a live update sync when the portal is added to the manager. + * The directory of the latest synced web application assets for this Portal. + * Returns null when no live update source is configured or no sync has completed. */ - var liveUpdateOnAppLoad: Boolean = true + fun latestAppDirectory(context: Context): File? { + return when (val source = liveUpdateSource) { + is LiveUpdateSource.Ionic -> LiveUpdateManager.getLatestAppDirectory(context, source.liveUpdateConfig.appId) + is LiveUpdateSource.Provider -> source.manager.latestAppDirectory + null -> null + } + } /** * Add a Capacitor [Plugin] to be loaded with this Portal. @@ -308,7 +337,7 @@ class PortalBuilder(val name: String) { private var initialContext: Any? = null private var portalFragmentType: Class = PortalFragment::class.java private var onCreate: (portal: Portal) -> Unit = {} - private var liveUpdateConfig: LiveUpdate? = null + private var liveUpdateSource: Portal.LiveUpdateSource? = null private var devMode: Boolean = true internal constructor(name: String, onCreate: (portal: Portal) -> Unit) : this(name) { @@ -526,31 +555,32 @@ class PortalBuilder(val name: String) { } /** - * Set the [LiveUpdate] config if using the Live Updates SDK with Portals. + * Set the [LiveUpdate] config if using Ionic Live Updates with Portals. * * Example usage (kotlin): * ```kotlin * val liveUpdateConfig = LiveUpdate("appId", "production") - * builder = builder.setLiveUpdateConfig(liveUpdateConfig) + * builder = builder.setLiveUpdateConfig(context, liveUpdateConfig) * ``` * * Example usage (java): * ```java * LiveUpdate liveUpdateConfig = new LiveUpdate("appId", "production"); - * builder = builder.setLiveUpdateConfig(liveUpdateConfig); + * builder = builder.setLiveUpdateConfig(context, liveUpdateConfig); * ``` * - * @param context the Android [Context] used with Live Update configuration - * @param liveUpdateConfig the Live Update config object - * @param updateOnAppLoad if a Live Update sync should occur as soon as the Portal loads - * @return the instance of the PortalBuilder with the Live Update config set + * @param context the Android [Context] used with Ionic Live Updates configuration. + * @param liveUpdateConfig the Ionic Live Updates config object. + * @param updateOnAppLoad whether to start an Ionic Live Updates sync when the Portal is configured. + * @return the instance of the PortalBuilder with the Ionic Live Updates config set. */ @JvmOverloads fun setLiveUpdateConfig(context: Context, liveUpdateConfig: LiveUpdate, updateOnAppLoad: Boolean = true): PortalBuilder { - this.liveUpdateConfig = liveUpdateConfig + requireNoLiveUpdateSource() if(liveUpdateConfig.assetPath == null) { liveUpdateConfig.assetPath = this._startDir ?: this.name } + this.liveUpdateSource = Portal.LiveUpdateSource.Ionic(liveUpdateConfig) LiveUpdateManager.initialize(context) LiveUpdateManager.cleanVersions(context, liveUpdateConfig.appId) @@ -561,6 +591,57 @@ class PortalBuilder(val name: String) { return this } + /** + * Set a live update provider manager to be used with the Portal. + * + * Example usage (kotlin): + * ```kotlin + * builder = builder.setLiveUpdateProviderManager(providerManager) + * ``` + * + * Example usage (java): + * ```java + * builder = builder.setLiveUpdateProviderManager(providerManager); + * ``` + * + * To trigger a provider sync after the Portal is created, use the provider manager directly. + * + * @param liveUpdateProviderManager the external live update provider manager. + * @param updateOnAppLoad whether to start an external provider sync when the Portal is configured. + * @return the instance of the PortalBuilder with the external live update provider manager set. + */ + @JvmOverloads + fun setLiveUpdateProviderManager( + liveUpdateProviderManager: LiveUpdateProviderManager, + updateOnAppLoad: Boolean = true + ): PortalBuilder { + requireNoLiveUpdateSource() + this.liveUpdateSource = Portal.LiveUpdateSource.Provider(liveUpdateProviderManager) + if (updateOnAppLoad) { + liveUpdateProviderManager.sync( + callback = object : LiveUpdateProviderSyncCallback { + override fun onSuccess(result: LiveUpdateProviderSyncResult) { + Log.d( + "PortalBuilder", + "Live Update sync complete. Latest app dir: ${liveUpdateProviderManager.latestAppDirectory}" + ) + } + + override fun onFailure(error: LiveUpdateProviderError.SyncFailed) { + Log.e("PortalBuilder", "Live Update sync failed: ${error.message}") + } + } + ) + } + return this + } + + private fun requireNoLiveUpdateSource() { + check(liveUpdateSource == null) { + "A live update source is already configured for this Portal." + } + } + /** * Set development mode on the Portal which will look for a server URL set by the Portals CLI. * This is set to true by default but can be turned off manually if desired. @@ -597,7 +678,7 @@ class PortalBuilder(val name: String) { portal.addAssetMaps(assetMaps) portal.initialContext = this.initialContext portal.portalFragmentType = this.portalFragmentType - portal.liveUpdateConfig = this.liveUpdateConfig + portal.liveUpdateSource = this.liveUpdateSource portal.devMode = this.devMode onCreate(portal) return portal diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt index f539674..df9ebe5 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt @@ -12,7 +12,6 @@ import androidx.annotation.NonNull import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.getcapacitor.* -import io.ionic.liveupdates.LiveUpdateManager import org.json.JSONException import org.json.JSONObject import java.io.File @@ -265,11 +264,11 @@ open class PortalFragment : Fragment { /** * Reloads the Portal. - * If Live Updates is used and the web content was updated, the new content will be loaded. + * If a live update source is configured and the web content was updated, the new content will be loaded. */ fun reload() { - if(portal?.liveUpdateConfig != null) { - val latestLiveUpdateFiles = LiveUpdateManager.getLatestAppDirectory(requireContext(), portal?.liveUpdateConfig?.appId!!) + if(portal?.liveUpdateSource != null) { + val latestLiveUpdateFiles = portal?.latestAppDirectory(requireContext()) if (latestLiveUpdateFiles != null) { if (liveUpdateFiles == null || liveUpdateFiles!!.path != latestLiveUpdateFiles.path) { liveUpdateFiles = latestLiveUpdateFiles @@ -323,8 +322,8 @@ open class PortalFragment : Fragment { .addPluginInstances(initialPluginInstances) .addWebViewListeners(webViewListeners) - if (portal?.liveUpdateConfig != null) { - liveUpdateFiles = LiveUpdateManager.getLatestAppDirectory(requireContext(), portal?.liveUpdateConfig?.appId!!) + if (portal?.liveUpdateSource != null) { + liveUpdateFiles = portal?.latestAppDirectory(requireContext()) bridgeBuilder = if (liveUpdateFiles != null) { if (config == null) { val configFile = File(liveUpdateFiles!!.path + "/capacitor.config.json") diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt index c296d31..407af5f 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt @@ -53,7 +53,7 @@ object PortalManager { * ``` * * @param name the portal name - * @throws NoSuchElementException throws this exception if the Portal does not exist + * @throws IllegalStateException throws this exception if the Portal does not exist */ @JvmStatic fun getPortal(name: String): Portal { @@ -62,8 +62,8 @@ object PortalManager { /** * Removes the Portal from the Portal Manager. The Portal will be returned if it was present. If not, null is returned. - * Note: if the Portal uses Live Updates and registered an instance on creation, the Live Update instance for the app - * is not removed. + * Note: removing a Portal does not remove its Ionic Live Updates app instance from the + * Ionic Live Updates manager. * * @param name the name of the Portal to remove */ @@ -92,27 +92,6 @@ object PortalManager { return portals.size } - /** - * Portals registration is no longer required. This function is retained for source - * compatibility and has no effect. - * - * @param key A previously required Portals registration key. - */ - @Deprecated("Portals registration is no longer required. This method has no effect.") - @JvmStatic - fun register(key: String) {} - - /** - * Portals registration is no longer required. - * - * @return true. - */ - @Deprecated("Portals registration is no longer required. This method always returns true.") - @JvmStatic - fun isRegistered(): Boolean { - return true - } - /** * A helper function to build portal classes and add them to the manager. * Classes built with newPortal are added to the PortalManager automatically. diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt index 6121816..9f4ec9a 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt @@ -40,7 +40,7 @@ import java.util.ArrayList * * ``` * - * Jetpack Composd example usage: + * Jetpack Compose example usage: * ```kotlin * @Composable * fun loadPortal(portalId: String) { @@ -361,4 +361,4 @@ class PortalView : FrameLayout { mDisappearingFragmentChildren!!.add(v) } } -} \ No newline at end of file +} From 1297192014fde2378c0adf66f83977516cb147d1 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:22:57 -0500 Subject: [PATCH 02/12] feat: more --- IonicPortals/build.gradle.kts | 4 +- .../main/kotlin/io/ionic/portals/Portal.kt | 179 ++++++++++++++---- .../kotlin/io/ionic/portals/PortalManager.kt | 2 +- settings.gradle | 12 ++ 4 files changed, 162 insertions(+), 35 deletions(-) diff --git a/IonicPortals/build.gradle.kts b/IonicPortals/build.gradle.kts index 8364e1c..112561d 100644 --- a/IonicPortals/build.gradle.kts +++ b/IonicPortals/build.gradle.kts @@ -49,7 +49,9 @@ dependencies { implementation(kotlin("reflect")) api("com.capacitorjs:core:[8.0.0,9.0.0)") - api("io.ionic:liveupdateprovider:0.1.0") + // Substituted with the local ../live-update-provider-sdk checkout (feat/api-0.2.0) + // via includeBuild in settings.gradle when that checkout is present. + api("io.ionic:liveupdateprovider:0.2.0") compileOnly("io.ionic:liveupdates:0.5.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index 4a04416..f28bc0e 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -1,15 +1,22 @@ package io.ionic.portals import android.content.Context -import android.util.Log import com.getcapacitor.Plugin -import io.ionic.liveupdateprovider.LiveUpdateProviderError -import io.ionic.liveupdateprovider.LiveUpdateProviderManager -import io.ionic.liveupdateprovider.LiveUpdateProviderSyncCallback -import io.ionic.liveupdateprovider.LiveUpdateProviderSyncResult +import io.ionic.liveupdateprovider.ProviderManager +import io.ionic.liveupdateprovider.ProviderSyncResult import io.ionic.liveupdates.LiveUpdate import io.ionic.liveupdates.LiveUpdateManager import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +private const val PROVIDER_SYNC_TIMEOUT_MS = 5 * 60 * 1_000L /** * A class representing a Portal that contains information about the web content to load and any @@ -41,9 +48,9 @@ class Portal(val name: String) { data class Ionic(val liveUpdateConfig: LiveUpdate) : LiveUpdateSource() /** - * Uses a [LiveUpdateProviderManager] to sync and locate the latest web application assets. + * Uses an external live update provider to sync and locate the latest web application assets. */ - data class Provider(val manager: LiveUpdateProviderManager) : LiveUpdateSource() + data class Provider(val manager: ProviderManager) : LiveUpdateSource() } /** @@ -61,6 +68,21 @@ class Portal(val name: String) { */ internal var assetMaps = LinkedHashMap() + /** + * A background scope used to run provider syncs started from [syncProviderAsync], since that + * method cannot suspend the caller. Canceled via [cancelPendingSyncs] when this Portal is + * discarded from [PortalManager]. + */ + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * Cancels any in-flight [syncProviderAsync] work for this Portal. Called by [PortalManager] + * when this Portal is removed. + */ + internal fun cancelPendingSyncs() { + coroutineScope.cancel() + } + /** * Initialize the Portal and add the PortalsPlugin by default. */ @@ -86,7 +108,7 @@ class Portal(val name: String) { * if this value is not set. */ var startDir: String = "" - get() = if (field.isEmpty()) name else field + get() = field.ifEmpty { name } /** * If the Portal should be loaded in development mode and look for a server URL. @@ -119,6 +141,120 @@ class Portal(val name: String) { } } + /** + * Syncs the external live update provider source if present. + * + * Example usage (kotlin): + * ```kotlin + * val result = portal.syncProvider() + * ``` + * + * Example usage (java): + * ```java + * // syncProvider() is a Kotlin suspend function and can't be called directly from Java. + * // Use syncProviderAsync instead: + * portal.syncProviderAsync(new Portal.ProviderSyncCallback() { + * @Override + * public void onSuccess(ProviderSyncResult result) { + * // handle result + * } + * + * @Override + * public void onFailure(Exception error) { + * // handle error + * } + * }); + * ``` + * + * @return the result of the synchronization operation, or null when no update is available. + * @throws LiveUpdateNotConfigured if this Portal has no [LiveUpdateSource.Provider] configured. + */ + suspend fun syncProvider(): ProviderSyncResult? { + val source = liveUpdateSource as? LiveUpdateSource.Provider ?: throw LiveUpdateNotConfigured() + return source.manager.sync() + } + + /** + * Syncs the external live update provider source if present, reporting the outcome to + * [callback] instead of suspending. This is the Java-friendly counterpart to [syncProvider]. + * + * The sync runs on a background coroutine scope owned by this Portal and is bounded by a + * [PROVIDER_SYNC_TIMEOUT_MS] timeout; [callback] is invoked with the result, or with the + * error (including [LiveUpdateNotConfigured] or a timeout) if the sync fails. + * + * Example usage (kotlin): + * ```kotlin + * portal.syncProviderAsync(object : Portal.ProviderSyncCallback { + * override fun onSuccess(result: ProviderSyncResult?) { + * // handle result + * } + * + * override fun onFailure(error: Exception) { + * // handle error + * } + * }) + * ``` + * + * Example usage (java): + * ```java + * portal.syncProviderAsync(new Portal.ProviderSyncCallback() { + * @Override + * public void onSuccess(ProviderSyncResult result) { + * // handle result + * } + * + * @Override + * public void onFailure(Exception error) { + * // handle error + * } + * }); + * ``` + * + * @param callback invoked with the result of the synchronization operation, or with the + * error if the sync fails. + */ + fun syncProviderAsync(callback: ProviderSyncCallback) { + coroutineScope.launch { + val result = try { + withTimeout(PROVIDER_SYNC_TIMEOUT_MS) { syncProvider() } + } catch (timeout: TimeoutCancellationException) { + callback.onFailure(timeout) + return@launch + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + callback.onFailure(error) + return@launch + } + callback.onSuccess(result) + } + } + + /** + * Callback used to report the outcome of a [syncProviderAsync] call to Java callers. + */ + interface ProviderSyncCallback { + /** + * Called when the sync completes successfully. + * + * @param result the result of the synchronization operation, or null when no update is available. + */ + fun onSuccess(result: ProviderSyncResult?) + + /** + * Called when the sync fails. + * + * @param error the error that caused the sync to fail. + */ + fun onFailure(error: Exception) + } + + /** + * Thrown when a live update sync is requested but the required live update source is not + * present on the [Portal]. + */ + class LiveUpdateNotConfigured : Exception("The requested live update source is not configured for this Portal.") + /** * Add a Capacitor [Plugin] to be loaded with this Portal. * @@ -427,7 +563,7 @@ class PortalBuilder(val name: String) { * @return the instance of the PortalBuilder with the Asset Map added */ fun addAssetMap(assetMap: AssetMap): PortalBuilder { - assetMaps.put(assetMap.getAssetPath(), assetMap) + assetMaps[assetMap.getAssetPath()] = assetMap return this } @@ -604,35 +740,12 @@ class PortalBuilder(val name: String) { * builder = builder.setLiveUpdateProviderManager(providerManager); * ``` * - * To trigger a provider sync after the Portal is created, use the provider manager directly. - * * @param liveUpdateProviderManager the external live update provider manager. - * @param updateOnAppLoad whether to start an external provider sync when the Portal is configured. * @return the instance of the PortalBuilder with the external live update provider manager set. */ - @JvmOverloads - fun setLiveUpdateProviderManager( - liveUpdateProviderManager: LiveUpdateProviderManager, - updateOnAppLoad: Boolean = true - ): PortalBuilder { + fun setLiveUpdateProviderManager(liveUpdateProviderManager: ProviderManager): PortalBuilder { requireNoLiveUpdateSource() this.liveUpdateSource = Portal.LiveUpdateSource.Provider(liveUpdateProviderManager) - if (updateOnAppLoad) { - liveUpdateProviderManager.sync( - callback = object : LiveUpdateProviderSyncCallback { - override fun onSuccess(result: LiveUpdateProviderSyncResult) { - Log.d( - "PortalBuilder", - "Live Update sync complete. Latest app dir: ${liveUpdateProviderManager.latestAppDirectory}" - ) - } - - override fun onFailure(error: LiveUpdateProviderError.SyncFailed) { - Log.e("PortalBuilder", "Live Update sync failed: ${error.message}") - } - } - ) - } return this } diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt index 407af5f..50905fc 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt @@ -69,7 +69,7 @@ object PortalManager { */ @JvmStatic fun removePortal(name: String): Portal? { - return portals.remove(name) + return portals.remove(name)?.also { it.cancelPendingSyncs() } } /** diff --git a/settings.gradle b/settings.gradle index 360dcf8..8632932 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,3 +17,15 @@ rootProject.name = "IonicPortals" include ':IonicPortals' include ':TestApp' include ':TestAppCompose' + +// Local dev: build io.ionic:liveupdateprovider from source against the sibling +// live-update-provider-sdk checkout instead of Maven Central, so we can work +// against its unreleased API. Remove once a matching release is published. +def liveUpdateProviderSdkDir = new File(rootDir, '../live-update-provider-sdk/android') +if (liveUpdateProviderSdkDir.exists()) { + includeBuild(liveUpdateProviderSdkDir) { + dependencySubstitution { + substitute module('io.ionic:liveupdateprovider') using project(':live-update-provider') + } + } +} From 14094986139f87dfefa4c2c09223b1ccaf2849b5 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:14:58 -0500 Subject: [PATCH 03/12] chore: more cleanup --- .../main/kotlin/io/ionic/portals/Portal.kt | 43 ++++++++----------- .../kotlin/io/ionic/portals/PortalManager.kt | 2 +- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index f28bc0e..b1f40f3 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -11,12 +11,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout - -private const val PROVIDER_SYNC_TIMEOUT_MS = 5 * 60 * 1_000L +import kotlinx.coroutines.withContext /** * A class representing a Portal that contains information about the web content to load and any @@ -70,19 +66,10 @@ class Portal(val name: String) { /** * A background scope used to run provider syncs started from [syncProviderAsync], since that - * method cannot suspend the caller. Canceled via [cancelPendingSyncs] when this Portal is - * discarded from [PortalManager]. + * method cannot suspend the caller. */ private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - /** - * Cancels any in-flight [syncProviderAsync] work for this Portal. Called by [PortalManager] - * when this Portal is removed. - */ - internal fun cancelPendingSyncs() { - coroutineScope.cancel() - } - /** * Initialize the Portal and add the PortalsPlugin by default. */ @@ -166,6 +153,9 @@ class Portal(val name: String) { * }); * ``` * + * This does not apply a timeout; the provider implementation is responsible for bounding its + * own sync operation, if desired. + * * @return the result of the synchronization operation, or null when no update is available. * @throws LiveUpdateNotConfigured if this Portal has no [LiveUpdateSource.Provider] configured. */ @@ -178,9 +168,9 @@ class Portal(val name: String) { * Syncs the external live update provider source if present, reporting the outcome to * [callback] instead of suspending. This is the Java-friendly counterpart to [syncProvider]. * - * The sync runs on a background coroutine scope owned by this Portal and is bounded by a - * [PROVIDER_SYNC_TIMEOUT_MS] timeout; [callback] is invoked with the result, or with the - * error (including [LiveUpdateNotConfigured] or a timeout) if the sync fails. + * The sync runs on a background coroutine scope owned by this Portal. [callback] is invoked + * on the main thread with the result, or with the error (including [LiveUpdateNotConfigured]) + * if the sync fails. This does not apply a timeout; see [syncProvider]. * * Example usage (kotlin): * ```kotlin @@ -215,18 +205,19 @@ class Portal(val name: String) { */ fun syncProviderAsync(callback: ProviderSyncCallback) { coroutineScope.launch { + var error: Exception? = null val result = try { - withTimeout(PROVIDER_SYNC_TIMEOUT_MS) { syncProvider() } - } catch (timeout: TimeoutCancellationException) { - callback.onFailure(timeout) - return@launch + syncProvider() } catch (cancellation: CancellationException) { throw cancellation - } catch (error: Exception) { - callback.onFailure(error) - return@launch + } catch (e: Exception) { + error = e + null + } + val failure = error + withContext(Dispatchers.Main) { + if (failure != null) callback.onFailure(failure) else callback.onSuccess(result) } - callback.onSuccess(result) } } diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt index 50905fc..407af5f 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt @@ -69,7 +69,7 @@ object PortalManager { */ @JvmStatic fun removePortal(name: String): Portal? { - return portals.remove(name)?.also { it.cancelPendingSyncs() } + return portals.remove(name) } /** From 8b03ecfd478bf65ef058a4f72ac29324c991fe25 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:02:10 -0500 Subject: [PATCH 04/12] chore: use released live-update-provider-sdk --- IonicPortals/build.gradle.kts | 4 +--- settings.gradle | 12 ------------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/IonicPortals/build.gradle.kts b/IonicPortals/build.gradle.kts index 112561d..3ac65ad 100644 --- a/IonicPortals/build.gradle.kts +++ b/IonicPortals/build.gradle.kts @@ -49,9 +49,7 @@ dependencies { implementation(kotlin("reflect")) api("com.capacitorjs:core:[8.0.0,9.0.0)") - // Substituted with the local ../live-update-provider-sdk checkout (feat/api-0.2.0) - // via includeBuild in settings.gradle when that checkout is present. - api("io.ionic:liveupdateprovider:0.2.0") + api("io.ionic:liveupdateprovider:1.0.0") compileOnly("io.ionic:liveupdates:0.5.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") diff --git a/settings.gradle b/settings.gradle index 8632932..360dcf8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,15 +17,3 @@ rootProject.name = "IonicPortals" include ':IonicPortals' include ':TestApp' include ':TestAppCompose' - -// Local dev: build io.ionic:liveupdateprovider from source against the sibling -// live-update-provider-sdk checkout instead of Maven Central, so we can work -// against its unreleased API. Remove once a matching release is published. -def liveUpdateProviderSdkDir = new File(rootDir, '../live-update-provider-sdk/android') -if (liveUpdateProviderSdkDir.exists()) { - includeBuild(liveUpdateProviderSdkDir) { - dependencySubstitution { - substitute module('io.ionic:liveupdateprovider') using project(':live-update-provider') - } - } -} From 4657a167364c62ba24cd3f866a5667928884b2f3 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:32:47 -0500 Subject: [PATCH 05/12] chore: cleanup --- .../src/main/kotlin/io/ionic/portals/DevConfiguration.kt | 8 ++++---- .../src/main/kotlin/io/ionic/portals/PortalView.kt | 7 +++---- .../src/main/kotlin/io/ionic/portals/PortalsPlugin.kt | 7 +++---- .../src/main/kotlin/io/ionic/portals/WebVitals.kt | 1 - 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/DevConfiguration.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/DevConfiguration.kt index 354d7f8..ac703c1 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/DevConfiguration.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/DevConfiguration.kt @@ -22,7 +22,7 @@ object DevConfiguration { assetManager.open("$portalDirName/$urlFileName").bufferedReader().use { it.readText() } - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -31,7 +31,7 @@ object DevConfiguration { assetManager.open("$generalDirName/$urlFileName").bufferedReader().use { it.readText() } - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -50,7 +50,7 @@ object DevConfiguration { var serverConfig = try { val configFile = context.assets.open("$portalDirName/$capConfigFileName") CapConfig.loadFromAssets(context, portalDirName) - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -58,7 +58,7 @@ object DevConfiguration { serverConfig = try { val configFile = context.assets.open("$generalDirName/$capConfigFileName") CapConfig.loadFromAssets(context, generalDirName) - } catch (e: Exception) { + } catch (_: Exception) { null } } diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt index 9f4ec9a..0dd3148 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt @@ -5,7 +5,6 @@ import android.app.Activity import android.content.Context import android.graphics.Canvas import android.os.Build -import android.os.Handler import android.util.AttributeSet import android.view.View import android.view.WindowInsets @@ -88,7 +87,7 @@ class PortalView : FrameLayout { this.onBridgeAvailable = onBridgeAvailable this.portalId = portalId this.viewId = viewId - this.id = View.generateViewId() + this.id = generateViewId() loadPortal(context, null) } @@ -100,7 +99,7 @@ class PortalView : FrameLayout { this.portal = portal this.portalId = portal.name this.viewId = viewId - this.id = View.generateViewId() + this.id = generateViewId() loadPortal(context, null) } @@ -264,7 +263,7 @@ class PortalView : FrameLayout { override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean { if (mDrawDisappearingViewsFirst && (mDisappearingFragmentChildren != null - ) && (mDisappearingFragmentChildren!!.size > 0) + ) && (mDisappearingFragmentChildren!!.isNotEmpty()) ) { // If the child is disappearing, we have already drawn it so skip. if (mDisappearingFragmentChildren!!.contains(child)) { diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalsPlugin.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalsPlugin.kt index 4559860..122701b 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalsPlugin.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalsPlugin.kt @@ -1,6 +1,5 @@ package io.ionic.portals -import android.util.Log import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin import org.json.JSONException @@ -65,7 +64,7 @@ class PortalsPubSub { } /** - * A special Capacitor Plugin within the Portals library that allows for bi-directional communication + * A special Capacitor Plugin within the Portals library that allows for bidirectional communication * between Android and web code. It is loaded with every Portal automatically and does not need to be * added like other plugins if the default behavior is desired. * @@ -91,7 +90,7 @@ class PortalsPlugin(private val pubSub: PortalsPubSub = PortalsPubSub.shared) : val data = try { call.data.get("data") - } catch (e: JSONException) { + } catch (_: JSONException) { null } @@ -130,7 +129,7 @@ class PortalsPlugin(private val pubSub: PortalsPubSub = PortalsPubSub.shared) : * @return a map representation of the JSONObject */ fun JSONObject.toMap(): Map { - val map = mutableMapOf(); + val map = mutableMapOf() this.keys().forEach { map[it] = this.get(it) } diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/WebVitals.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/WebVitals.kt index d96fb7d..7008a49 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/WebVitals.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/WebVitals.kt @@ -4,7 +4,6 @@ import android.webkit.JavascriptInterface import android.webkit.WebView import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin -import org.json.JSONObject /** * A class providing Web Vitals functionality. When Web Vitals metrics are desired, this class adds From 80f78d3de62d380c6916a53b8e383471bc3edc66 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:15:12 -0500 Subject: [PATCH 06/12] feat: remove sync actually --- .../main/kotlin/io/ionic/portals/Portal.kt | 150 +----------------- .../kotlin/io/ionic/portals/PortalFragment.kt | 30 ++-- .../kotlin/io/ionic/portals/PortalManager.kt | 3 +- 3 files changed, 19 insertions(+), 164 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index b1f40f3..f17d038 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -1,18 +1,15 @@ package io.ionic.portals import android.content.Context +import com.getcapacitor.Logger import com.getcapacitor.Plugin import io.ionic.liveupdateprovider.ProviderManager -import io.ionic.liveupdateprovider.ProviderSyncResult import io.ionic.liveupdates.LiveUpdate import io.ionic.liveupdates.LiveUpdateManager import java.io.File -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** * A class representing a Portal that contains information about the web content to load and any @@ -64,12 +61,6 @@ class Portal(val name: String) { */ internal var assetMaps = LinkedHashMap() - /** - * A background scope used to run provider syncs started from [syncProviderAsync], since that - * method cannot suspend the caller. - */ - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - /** * Initialize the Portal and add the PortalsPlugin by default. */ @@ -103,17 +94,15 @@ class Portal(val name: String) { var devMode: Boolean = true /** - * The live update source for this Portal. - * - * Use [LiveUpdateSource.Ionic] for Ionic Live Updates, or [LiveUpdateSource.Provider] for - * an external provider built with the Live Update Provider SDK. + * The live update source for this Portal — [LiveUpdateSource.Ionic] for Ionic Live Updates, or + * [LiveUpdateSource.Provider] for an external provider built with the Live Update Provider SDK. */ var liveUpdateSource: LiveUpdateSource? = null set(value) { - field = value if (value is LiveUpdateSource.Ionic && value.liveUpdateConfig.assetPath == null) { value.liveUpdateConfig.assetPath = this.startDir } + field = value } /** @@ -128,124 +117,6 @@ class Portal(val name: String) { } } - /** - * Syncs the external live update provider source if present. - * - * Example usage (kotlin): - * ```kotlin - * val result = portal.syncProvider() - * ``` - * - * Example usage (java): - * ```java - * // syncProvider() is a Kotlin suspend function and can't be called directly from Java. - * // Use syncProviderAsync instead: - * portal.syncProviderAsync(new Portal.ProviderSyncCallback() { - * @Override - * public void onSuccess(ProviderSyncResult result) { - * // handle result - * } - * - * @Override - * public void onFailure(Exception error) { - * // handle error - * } - * }); - * ``` - * - * This does not apply a timeout; the provider implementation is responsible for bounding its - * own sync operation, if desired. - * - * @return the result of the synchronization operation, or null when no update is available. - * @throws LiveUpdateNotConfigured if this Portal has no [LiveUpdateSource.Provider] configured. - */ - suspend fun syncProvider(): ProviderSyncResult? { - val source = liveUpdateSource as? LiveUpdateSource.Provider ?: throw LiveUpdateNotConfigured() - return source.manager.sync() - } - - /** - * Syncs the external live update provider source if present, reporting the outcome to - * [callback] instead of suspending. This is the Java-friendly counterpart to [syncProvider]. - * - * The sync runs on a background coroutine scope owned by this Portal. [callback] is invoked - * on the main thread with the result, or with the error (including [LiveUpdateNotConfigured]) - * if the sync fails. This does not apply a timeout; see [syncProvider]. - * - * Example usage (kotlin): - * ```kotlin - * portal.syncProviderAsync(object : Portal.ProviderSyncCallback { - * override fun onSuccess(result: ProviderSyncResult?) { - * // handle result - * } - * - * override fun onFailure(error: Exception) { - * // handle error - * } - * }) - * ``` - * - * Example usage (java): - * ```java - * portal.syncProviderAsync(new Portal.ProviderSyncCallback() { - * @Override - * public void onSuccess(ProviderSyncResult result) { - * // handle result - * } - * - * @Override - * public void onFailure(Exception error) { - * // handle error - * } - * }); - * ``` - * - * @param callback invoked with the result of the synchronization operation, or with the - * error if the sync fails. - */ - fun syncProviderAsync(callback: ProviderSyncCallback) { - coroutineScope.launch { - var error: Exception? = null - val result = try { - syncProvider() - } catch (cancellation: CancellationException) { - throw cancellation - } catch (e: Exception) { - error = e - null - } - val failure = error - withContext(Dispatchers.Main) { - if (failure != null) callback.onFailure(failure) else callback.onSuccess(result) - } - } - } - - /** - * Callback used to report the outcome of a [syncProviderAsync] call to Java callers. - */ - interface ProviderSyncCallback { - /** - * Called when the sync completes successfully. - * - * @param result the result of the synchronization operation, or null when no update is available. - */ - fun onSuccess(result: ProviderSyncResult?) - - /** - * Called when the sync fails. - * - * @param error the error that caused the sync to fail. - */ - fun onFailure(error: Exception) - } - - /** - * Thrown when a live update sync is requested but the required live update source is not - * present on the [Portal]. - */ - class LiveUpdateNotConfigured : Exception("The requested live update source is not configured for this Portal.") - /** * Add a Capacitor [Plugin] to be loaded with this Portal. * @@ -703,7 +574,7 @@ class PortalBuilder(val name: String) { */ @JvmOverloads fun setLiveUpdateConfig(context: Context, liveUpdateConfig: LiveUpdate, updateOnAppLoad: Boolean = true): PortalBuilder { - requireNoLiveUpdateSource() + check(liveUpdateSource == null) { "A live update source is already configured for this Portal." } if(liveUpdateConfig.assetPath == null) { liveUpdateConfig.assetPath = this._startDir ?: this.name } @@ -731,21 +602,16 @@ class PortalBuilder(val name: String) { * builder = builder.setLiveUpdateProviderManager(providerManager); * ``` * - * @param liveUpdateProviderManager the external live update provider manager. + * @param liveUpdateProviderManager the external live update provider manager. Whether and when it syncs + * (e.g. on construction) is up to the provider implementation itself. * @return the instance of the PortalBuilder with the external live update provider manager set. */ fun setLiveUpdateProviderManager(liveUpdateProviderManager: ProviderManager): PortalBuilder { - requireNoLiveUpdateSource() + check(liveUpdateSource == null) { "A live update source is already configured for this Portal." } this.liveUpdateSource = Portal.LiveUpdateSource.Provider(liveUpdateProviderManager) return this } - private fun requireNoLiveUpdateSource() { - check(liveUpdateSource == null) { - "A live update source is already configured for this Portal." - } - } - /** * Set development mode on the Portal which will look for a server URL set by the Portals CLI. * This is set to true by default but can be turned off manually if desired. diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt index df9ebe5..2311fd5 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt @@ -298,7 +298,7 @@ open class PortalFragment : Fragment { if (existingPortalName != null && portal == null) { try { portal = PortalManager.getPortal(existingPortalName) - } catch (e: Exception) { + } catch (_: Exception) { Logger.warn("Attempted to reload PortalFragment from App restore but portal not found.") Logger.warn("No portal named $existingPortalName found in PortalManager to use.") Logger.warn("Portal reload is unsuccessful. This is likely okay and safe to ignore if your app is returning from a force quit state.") @@ -322,27 +322,17 @@ open class PortalFragment : Fragment { .addPluginInstances(initialPluginInstances) .addWebViewListeners(webViewListeners) - if (portal?.liveUpdateSource != null) { - liveUpdateFiles = portal?.latestAppDirectory(requireContext()) - bridgeBuilder = if (liveUpdateFiles != null) { - if (config == null) { - val configFile = File(liveUpdateFiles!!.path + "/capacitor.config.json") - if(configFile.exists()) { - configToUse = CapConfig.loadFromFile(requireContext(), liveUpdateFiles!!.path) - } - } + liveUpdateFiles = if (portal?.liveUpdateSource != null) portal?.latestAppDirectory(requireContext()) else null - bridgeBuilder.setServerPath(ServerPath(ServerPath.PathType.BASE_PATH, liveUpdateFiles!!.path)) - } else { - if (config == null) { - try { - val configFile = requireContext().assets.open("$startDir/capacitor.config.json") - configToUse = CapConfig.loadFromAssets(requireContext(), startDir) - } catch (_: Exception) {} + bridgeBuilder = if (liveUpdateFiles != null) { + if (config == null) { + val configFile = File(liveUpdateFiles!!.path + "/capacitor.config.json") + if(configFile.exists()) { + configToUse = CapConfig.loadFromFile(requireContext(), liveUpdateFiles!!.path) } - - bridgeBuilder.setServerPath(ServerPath(ServerPath.PathType.ASSET_PATH, startDir)) } + + bridgeBuilder.setServerPath(ServerPath(ServerPath.PathType.BASE_PATH, liveUpdateFiles!!.path)) } else { if (config == null) { try { @@ -351,7 +341,7 @@ open class PortalFragment : Fragment { } catch (_: Exception) {} } - bridgeBuilder = bridgeBuilder.setServerPath(ServerPath(ServerPath.PathType.ASSET_PATH, startDir)) + bridgeBuilder.setServerPath(ServerPath(ServerPath.PathType.ASSET_PATH, startDir)) } portal?.assetMaps?.let { diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt index 407af5f..960de33 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalManager.kt @@ -62,8 +62,7 @@ object PortalManager { /** * Removes the Portal from the Portal Manager. The Portal will be returned if it was present. If not, null is returned. - * Note: removing a Portal does not remove its Ionic Live Updates app instance from the - * Ionic Live Updates manager. + * Note: removing a Portal does not remove its Ionic Live Updates app instance from the Ionic Live Updates manager. * * @param name the name of the Portal to remove */ From 7c5e0578d87b1eef50f7369126c830514ac03d60 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:16:20 -0500 Subject: [PATCH 07/12] chore: remove unused imports --- IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index f17d038..6283848 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -1,15 +1,11 @@ package io.ionic.portals import android.content.Context -import com.getcapacitor.Logger import com.getcapacitor.Plugin import io.ionic.liveupdateprovider.ProviderManager import io.ionic.liveupdates.LiveUpdate import io.ionic.liveupdates.LiveUpdateManager import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch /** * A class representing a Portal that contains information about the web content to load and any From d639fcdfd4463c9e3e931304905087ebf9145f96 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:25:21 -0500 Subject: [PATCH 08/12] chore: remove unused imports 2 --- IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt | 3 +-- .../src/main/kotlin/io/ionic/portals/PortalFragment.kt | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index 6283848..cc06f04 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -598,8 +598,7 @@ class PortalBuilder(val name: String) { * builder = builder.setLiveUpdateProviderManager(providerManager); * ``` * - * @param liveUpdateProviderManager the external live update provider manager. Whether and when it syncs - * (e.g. on construction) is up to the provider implementation itself. + * @param liveUpdateProviderManager the external live update provider manager. * @return the instance of the PortalBuilder with the external live update provider manager set. */ fun setLiveUpdateProviderManager(liveUpdateProviderManager: ProviderManager): PortalBuilder { diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt index 2311fd5..bfda54f 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt @@ -8,7 +8,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.JavascriptInterface -import androidx.annotation.NonNull import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.getcapacitor.* @@ -160,7 +159,7 @@ open class PortalFragment : Fragment { /** * Extends the Android Fragment 'onConfigurationChanged' event. */ - override fun onConfigurationChanged(@NonNull newConfig: Configuration) { + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) bridge?.onConfigurationChanged(newConfig) } @@ -405,7 +404,7 @@ open class PortalFragment : Fragment { is String -> { try { JSONObject(initialContext) - } catch (ex: JSONException) { + } catch (_: JSONException) { throw Error("initialContext must be a JSON string or a Map") } } @@ -473,7 +472,7 @@ open class PortalFragment : Fragment { when (member.parameters.size) { 1 -> { - val ref = pubSub.subscribe(methodName) { result -> + val ref = pubSub.subscribe(methodName) { _ -> member.call(messageReceiverParent) } subscriptions[methodName] = ref From 0e2b5e77d9d0ac1a86036c433d82771990acb662 Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:41:22 -0500 Subject: [PATCH 09/12] chore: more cleanup --- .../src/main/kotlin/io/ionic/portals/Portal.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index cc06f04..711de24 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -103,7 +103,6 @@ class Portal(val name: String) { /** * The directory of the latest synced web application assets for this Portal. - * Returns null when no live update source is configured or no sync has completed. */ fun latestAppDirectory(context: Context): File? { return when (val source = liveUpdateSource) { @@ -563,18 +562,17 @@ class PortalBuilder(val name: String) { * builder = builder.setLiveUpdateConfig(context, liveUpdateConfig); * ``` * - * @param context the Android [Context] used with Ionic Live Updates configuration. - * @param liveUpdateConfig the Ionic Live Updates config object. - * @param updateOnAppLoad whether to start an Ionic Live Updates sync when the Portal is configured. + * @param context the Android [Context] used with live update configuration. + * @param liveUpdateConfig the live update config object. + * @param updateOnAppLoad if a sync should occur as soon as the Portal loads * @return the instance of the PortalBuilder with the Ionic Live Updates config set. */ @JvmOverloads fun setLiveUpdateConfig(context: Context, liveUpdateConfig: LiveUpdate, updateOnAppLoad: Boolean = true): PortalBuilder { - check(liveUpdateSource == null) { "A live update source is already configured for this Portal." } + this.liveUpdateSource = Portal.LiveUpdateSource.Ionic(liveUpdateConfig) if(liveUpdateConfig.assetPath == null) { liveUpdateConfig.assetPath = this._startDir ?: this.name } - this.liveUpdateSource = Portal.LiveUpdateSource.Ionic(liveUpdateConfig) LiveUpdateManager.initialize(context) LiveUpdateManager.cleanVersions(context, liveUpdateConfig.appId) @@ -602,7 +600,6 @@ class PortalBuilder(val name: String) { * @return the instance of the PortalBuilder with the external live update provider manager set. */ fun setLiveUpdateProviderManager(liveUpdateProviderManager: ProviderManager): PortalBuilder { - check(liveUpdateSource == null) { "A live update source is already configured for this Portal." } this.liveUpdateSource = Portal.LiveUpdateSource.Provider(liveUpdateProviderManager) return this } From e08eac073013954e5aa6ff28814c023b1f7c8afd Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:43:16 -0500 Subject: [PATCH 10/12] chore: more cleanup --- IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index 711de24..ecb66a7 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -565,7 +565,7 @@ class PortalBuilder(val name: String) { * @param context the Android [Context] used with live update configuration. * @param liveUpdateConfig the live update config object. * @param updateOnAppLoad if a sync should occur as soon as the Portal loads - * @return the instance of the PortalBuilder with the Ionic Live Updates config set. + * @return the instance of the PortalBuilder with the live update config set. */ @JvmOverloads fun setLiveUpdateConfig(context: Context, liveUpdateConfig: LiveUpdate, updateOnAppLoad: Boolean = true): PortalBuilder { From 23036f514f812c44636da4716f14adc411aeb8fc Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:15:46 -0500 Subject: [PATCH 11/12] feat: add sync methods --- IonicPortals/build.gradle.kts | 4 +- .../main/kotlin/io/ionic/portals/Portal.kt | 55 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/IonicPortals/build.gradle.kts b/IonicPortals/build.gradle.kts index 3ac65ad..2d9c902 100644 --- a/IonicPortals/build.gradle.kts +++ b/IonicPortals/build.gradle.kts @@ -52,11 +52,9 @@ dependencies { api("io.ionic:liveupdateprovider:1.0.0") compileOnly("io.ionic:liveupdates:0.5.5") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - implementation("androidx.core:core-ktx:1.15.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.7.3") implementation("androidx.fragment:fragment-ktx:1.8.5") implementation("androidx.appcompat:appcompat:1.7.0") - implementation("com.google.android.material:material:1.12.0") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index ecb66a7..d8ac420 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -3,9 +3,14 @@ package io.ionic.portals import android.content.Context import com.getcapacitor.Plugin import io.ionic.liveupdateprovider.ProviderManager +import io.ionic.liveupdateprovider.ProviderSyncResult import io.ionic.liveupdates.LiveUpdate import io.ionic.liveupdates.LiveUpdateManager import java.io.File +import java.util.concurrent.CompletableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.future /** * A class representing a Portal that contains information about the web content to load and any @@ -112,6 +117,52 @@ class Portal(val name: String) { } } + /** + * Syncs the external live update provider source if present. + * + * Example usage (kotlin): + * ```kotlin + * val result = portal.syncProvider() + * ``` + * + * This is a suspend function and can't be called directly from Java — use [syncProviderAsync] instead. + * + * @return the result of the synchronization operation. + * @throws LiveUpdateNotConfigured if this Portal has no [LiveUpdateSource.Provider] configured. + */ + suspend fun syncProvider(): ProviderSyncResult? { + val source = liveUpdateSource as? LiveUpdateSource.Provider ?: throw LiveUpdateNotConfigured() + return source.manager.sync() + } + + /** + * Syncs the external live update provider source if present, returning a [CompletableFuture] + * instead of suspending. This is the Java-friendly counterpart to [syncProvider]. + * + * Example usage (java): + * ```java + * portal.syncProviderAsync().thenAccept(result -> { + * // handle result + * }).exceptionally(error -> { + * // handle error (including LiveUpdateNotConfigured) + * return null; + * }); + * ``` + * + * Kotlin callers should prefer [syncProvider] directly; use this only if you specifically need a + * [CompletableFuture], e.g. for interop with existing Future-based code. + * + * @return a [CompletableFuture] completed with the result of the synchronization operation, + * or completed exceptionally if the sync fails. + */ + fun syncProviderAsync(): CompletableFuture = CoroutineScope(Dispatchers.IO).future { syncProvider() } + + /** + * Thrown when a live update sync is requested but the required live update source is not + * present on the [Portal]. + */ + class LiveUpdateNotConfigured : Exception("The requested live update source is not configured for this Portal.") + /** * Add a Capacitor [Plugin] to be loaded with this Portal. * @@ -596,7 +647,9 @@ class PortalBuilder(val name: String) { * builder = builder.setLiveUpdateProviderManager(providerManager); * ``` * - * @param liveUpdateProviderManager the external live update provider manager. + * @param liveUpdateProviderManager the external live update provider manager. Whether and when it syncs + * on its own (e.g. on construction) is up to the provider implementation; use [Portal.syncProvider]/ + * [Portal.syncProviderAsync] to trigger a sync manually. * @return the instance of the PortalBuilder with the external live update provider manager set. */ fun setLiveUpdateProviderManager(liveUpdateProviderManager: ProviderManager): PortalBuilder { From 17dce78cde57b68c4a0169cf616dc763714ee2bd Mon Sep 17 00:00:00 2001 From: Trevor Lambert <78672774+trevor-lambert@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:40:08 -0500 Subject: [PATCH 12/12] chore: cleanup files --- .../main/kotlin/io/ionic/portals/Portal.kt | 30 +++++++++---------- .../kotlin/io/ionic/portals/PortalFragment.kt | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt index d8ac420..2f59a86 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/Portal.kt @@ -32,21 +32,6 @@ import kotlinx.coroutines.future.future * @property name the name of the Portal */ class Portal(val name: String) { - /** - * The live update source for a [Portal]. - */ - sealed class LiveUpdateSource { - /** - * Uses Ionic Live Updates to sync and locate the latest web application assets. - */ - data class Ionic(val liveUpdateConfig: LiveUpdate) : LiveUpdateSource() - - /** - * Uses an external live update provider to sync and locate the latest web application assets. - */ - data class Provider(val manager: ProviderManager) : LiveUpdateSource() - } - /** * Capacitor [Plugin] registered with the Portal. */ @@ -94,6 +79,21 @@ class Portal(val name: String) { */ var devMode: Boolean = true + /** + * The live update source for a [Portal]. + */ + sealed class LiveUpdateSource { + /** + * Uses Ionic Live Updates to sync and locate the latest web application assets. + */ + data class Ionic(val liveUpdateConfig: LiveUpdate) : LiveUpdateSource() + + /** + * Uses an external live update provider to sync and locate the latest web application assets. + */ + data class Provider(val manager: ProviderManager) : LiveUpdateSource() + } + /** * The live update source for this Portal — [LiveUpdateSource.Ionic] for Ionic Live Updates, or * [LiveUpdateSource.Provider] for an external provider built with the Live Update Provider SDK. diff --git a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt index bfda54f..a7f4505 100644 --- a/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt +++ b/IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt @@ -321,7 +321,7 @@ open class PortalFragment : Fragment { .addPluginInstances(initialPluginInstances) .addWebViewListeners(webViewListeners) - liveUpdateFiles = if (portal?.liveUpdateSource != null) portal?.latestAppDirectory(requireContext()) else null + liveUpdateFiles = portal?.latestAppDirectory(requireContext()) bridgeBuilder = if (liveUpdateFiles != null) { if (config == null) {