Android project requirements
System requirements:
- minSdk = 26 (Android 8.0)
- compileSdk = 34
The SDK library was build using:
Installing the library
- Add the maven repository in your project’s
settings.gradle.kts file:
dependencyResolutionManagement {
...
repositories {
...
maven {
url = uri("https://nexus-jx.dev.rd.flowx.ai/repository/flowx-maven-releases/")
credentials {
username = "your_username"
password = "your_password"
}
}
}
}
- Add the library as a dependency in your
app/build.gradle.kts file:
dependencies {
...
implementation("ai.flowx.android:android-sdk:4.0.9")
...
}
Library dependencies
Impactful dependencies:
Public API
The SDK library is managed through the FlowxSdkApi singleton instance, which exposes the following methods:
| Name | Description | Definition |
|---|
init | Initializes the FlowX SDK. Must be called in your application’s onCreate() | fun init(context: Context, config: SdkConfig, accessTokenProvider: FlowxSdkApi.Companion.AccessTokenProvider? = null, customComponentsProvider: CustomComponentsProvider? = null, customStepperHeaderProvider: CustomStepperHeaderProvider? = null) |
setAccessTokenProvider | Updates the access token provider (i.e. a functional interface) inside the renderer | fun setAccessTokenProvider(accessTokenProvider: FlowxSdkApi.Companion.AccessTokenProvider) |
setupTheme | Sets up the theme to be used when rendering a process | fun setupTheme(themeUuid: String, fallbackThemeJsonFileAssetsPath: String? = null, @MainThread onCompletion: () -> Unit) |
changeLocaleSettings | Changes the current locale settings (i.e. locale and language) | fun changeLocaleSettings(locale: Locale, language: String) |
startProcess | Starts a FlowX process instance, by returning a @Composable function where the process is rendered. | fun startProcess(projectId: String, processName: String, params: JSONObject = JSONObject(), isModal: Boolean = false, closeModalFunc: ((processName: String) -> Unit)? = null): @Composable () -> Unit |
continueProcess | Continues an existing FlowX process instance, by returning a @Composable function where the process is rendered. | fun continueProcess(processUuid: String, isModal: Boolean = false, closeModalFunc: ((processName: String) -> Unit)? = null): @Composable () -> Unit |
executeAction | Runs an action from a custom component | fun executeAction(action: CustomComponentAction, params: JSONObject? = null) |
getMediaResourceUrl | Extracts a media item URL needed to populate the UI of a custom component | fun getMediaResourceUrl(key: String): String? |
replaceSubstitutionTag | Extracts a substitution tag value needed to populate the UI of a custom component | fun replaceSubstitutionTag(key: String): String |
Configuring the library
To configure the SDK, call the init method in your project’s application class onCreate() method:
fun init(
context: Context,
config: SdkConfig,
accessTokenProvider: AccessTokenProvider? = null,
customComponentsProvider: CustomComponentsProvider? = null,
customStepperHeaderProvider: CustomStepperHeaderProvider? = null,
analyticsCollector = null,
onNewProcessStarted = null,
)
Parameters
| Name | Description | Type | Requirement |
|---|
context | Android application Context | Context | Mandatory |
config | SDK configuration parameters | ai.flowx.android.sdk.process.model.SdkConfig | Mandatory |
accessTokenProvider | Functional interface provider for passing the access token | ai.flowx.android.sdk.FlowxSdkApi.Companion.AccessTokenProvder? | Optional. Defaults to null. |
customComponentsProvider | Provider for the @Composable/View custom components | ai.flowx.android.sdk.component.custom.CustomComponentsProvider? | Optional. Defaults to null. |
customStepperHeaderProvider | Provider for the @Composable custom stepper header view | ai.flowx.android.sdk.component.custom.CustomStepperHeaderProvider? | Optional. Defaults to null. |
analyticsCollector | Collector interface for SDK analytics events | ai.flowx.android.sdk.analytics.AnalyticsCollector | Optional. Defaults to null. |
onNewProcessStarted | Callback for when a new process was started as a consequence for executing a START_PROJECT action | ai.flowx.android.sdk.NewProcessStartedHandler | Optional. Defaults to null. |
• Providing the access token is explained in the authentication section.
• The custom components implementation is explained in its own section.
• The implementation for providing a custom view for the header of the Stepper component is detailed in its own section.
• Collecting analytics events from the SDK is explained in its own section.
• Handling the start of a new process while in a running process is explained in its own section.
Sample
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
initFlowXSdk()
}
private fun initFlowXSdk() {
FlowxSdkApi.getInstance().init(
context = applicationContext,
config = SdkConfig(
baseUrl = "URL to FlowX backend",
imageBaseUrl = "URL to FlowX CMS Media Library",
enginePath = "some_path",
language = "en",
locale = Locale.getDefault(),
validators = mapOf("exact_25_in_length" to { it.length == 25 }),
enableLog = false,
),
accessTokenProvider = null, // null by default; can be set later, depending on the existing authentication logic
customComponentsProvider = object : CustomComponentsProvider {...},
customStepperHeaderProvider = object : CustomStepperHeaderProvider {...},
analyticsCollector = { event ->
Log.i("Analytics", "Event(type = ${event.type}, value = ${event.value})")
},
onNewProcessStarted = { processInstanceUuid ->
// Send a broadcast message to notify the Activity currently displaying the running process.
// The Activity should handle the broadcast to reload and display the newly started process identified by `processInstanceUuid`.
}
)
}
}
The configuration properties that should be passed as SdkConfig data for the config parameter above are:
| Name | Description | Type | Requirement |
|---|
baseUrl | URL to connect to the FlowX back-end environment | String | Mandatory |
imageBaseUrl | URL to connect to the FlowX Media Library module of the CMS | String | Mandatory |
enginePath | URL path segment used to identify the process engine service | String | Mandatory |
language | The language used for retrieving enumerations and substitution tags | String | Optional. Defaults to en. |
locale | The locale used for date, number and currency formatting | java.util.Locale | Optional. Defaults to Locale.getDefault() |
validators | Custom validators for form elements | Map<String, (String) -> Boolean>? | Optional. |
enableLog | Flag indicating if logs should be printed | Boolean | Optional. Defaults to false |
Custom validators
The custom validators map is a collection of lambda functions, referenced by name (i.e. the value of the key in this map), each returning a Boolean based on the String which needs to be validated.
For a custom validator to be evaluated for a form field, its name must be specified in the form field process definition.
By looking at the example from above:mapOf("exact_25_in_length" to { it.length == 25 })
if a form element should be validated using this lambda function, a custom validator named "exact_25_in_length" should be specified in the process definition.
Using the library
Authentication
To be able to use the SDK, authentication is required. Therefore, before calling any other method on the singleton instance, make sure that the access token provider is set by calling:
FlowxSdkApi.getInstance().setAccessTokenProvider(accessTokenProvider = { "your access token" })
The lambda passed in as parameter has the ai.flowx.android.sdk.FlowxSdkApi.Companion.AccessTokenProvider type, which is actually a functional interface defined like this:
fun interface AccessTokenProvider {
fun get(): String
}
Whenever the access token changes based on your own authentication logic, it must be updated in the renderer by calling the setAccessTokenProvider method again.
Theming
Prior setting up the theme, make sure the
access token provider was set.
Check the
authentication section for details.
To be able to use styled components while rendering a process, the theming mechanism must be invoked by calling the suspend-ing setupTheme(...) method over the singleton instance of the SDK:
suspend fun setupTheme(
themeUuid: String,
fallbackThemeJsonFileAssetsPath: String? = null,
@MainThread onCompletion: () -> Unit
)
Parameters
| Name | Description | Type | Requirement |
|---|
themeUuid | UUID string of the theme configured in FlowX Designer | String | Mandatory. Can be empty |
fallbackThemeJsonFileAssetsPath | Android asset relative path to the corresponding JSON file to be used as fallback, in case fetching the theme fails and there is no cached version available | String? | Optional. Defaults to null |
onCompletion | @MainThread invoked closure, called when setting up the theme completes | () -> Unit | Mandatory |
If the themeUuid parameter value is empty (""), no theme will be fetched, and the mechanism will rely only on the fallback file, if set.
If the fallbackThemeJsonFileAssetsPath parameter value is null, there will be no fallback mechanism set in place, meaning if fetching the theme fails, the redered process will have no style applied over it’s displayed components.
The SDK caches the fetched themes, so if a theme fetch fails, a cached version will be used, if available. Otherwise, it will use the file given as fallback.
Sample
viewModelScope.launch {
FlowxSdkApi.getInstance().setupTheme(
themeUuid = "some uuid string",
fallbackThemeJsonFileAssetsPath = "theme/a_fallback_theme.json",
) {
// theme setup complete
// do specific logic
}
}
The fallbackThemeJsonFileAssetsPath always search for files under your project’s assets/ directory, meaning the example parameter value is translated to file://android_asset/theme/a_fallback_theme.json before being evaluated.
Do not
start or
resume a process before the completion of the theme setup mechanism.
Changing current locale settings
The current locale and language can be also changed after the initial setup, by calling the changeLocaleSettings function:
fun changeLocaleSettings(locale: Locale, language: String)
Parameters
| Name | Description | Type | Requirement |
|---|
locale | The new locale | java.util.Locale | Mandatory |
language | The code for the new language | String | Mandatory |
Do not change the locale or the language while a process is rendered.
The change is successful only if made before starting or resuming a process.
Sample
FlowxSdkApi.getInstance().changeLocaleSettings(locale = Locale("en", "US"), language = "en")
Start a FlowX process
After performing all the above steps and all the prerequisites are fulfilled, a new instance of a FlowX process can be started, by using the startProcess function:
fun startProcess(
projectId: String,
processName: String,
params: JSONObject = JSONObject(),
isModal: Boolean = false,
onProcessEnded: (() -> Unit)? = null,
closeModalFunc: ((processName: String) -> Unit)? = null,
): @Composable () -> Unit
Parameters
| Name | Description | Type | Requirement |
|---|
projectId | The id of the project containing the process to be started | String | Mandatory |
processName | The name of the process | String | Mandatory |
params | The starting params for the process, if any | JSONObject | Optional. If omitted, if defaults to JSONObject() |
isModal | Flag indicating whether the process can be closed at anytime by tapping the top-right close button | Boolean | Optional. It defaults to false. |
onProcessEnded | Lambda function where you can do additional processing when the started process ends | (() -> Unit)? | Optional. It defaults to null. |
closeModalFunc | Lambda function where you should handle closing the process when isModal flag is true | ((processName: String) -> Unit)? | Optional. It defaults to null. |
The returned @Composable function must be included in its own Activity, which is part of (controlled and maintained by) the container application.
This wrapper activity must display only the @Composable returned from the SDK (i.e. it occupies the whole activity screen space).
Sample
class ProcessActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
setContent {
FlowxSdkApi.getInstance().startProcess(
projectId = "your project id",
processName = "your process name",
params: JSONObject = JSONObject(),
isModal = true,
onProcessEnded = {
// NOTE: possible processing could involve doing something in the container app (i.e. navigating to a different screen)
},
closeModalFunc = { processName ->
// NOTE: possible handling could involve doing something differently based on the `processName` value
},
).invoke()
}
}
...
}
Resume a FlowX process
To resume an existing instance of a FlowX process, after fulfilling all the prerequisites, use the continueProcess function:
fun continueProcess(
processUuid: String,
isModal: Boolean = false,
onProcessEnded: (() -> Unit)? = null,
closeModalFunc: ((processName: String) -> Unit)? = null,
): @Composable () -> Unit
Parameters
| Name | Description | Type | Requirement |
|---|
processUuid | The UUID string of the process | String | Mandatory |
isModal | Flag indicating whether the process can be closed at anytime by tapping the top-right close button | Boolean | Optional. It defaults to false. |
onProcessEnded | Lambda function where you can do additional processing when the continued process ends | (() -> Unit)? | Optional. It defaults to null. |
closeModalFunc | Lambda function where you should handle closing the process when isModal flag is true | ((processName: String) -> Unit)? | Optional. It defaults to null. |
The returned @Composable function must be included in its own Activity, which is part of (controlled and maintained by) the container application.
This wrapper activity must display only the @Composable returned from the SDK (i.e. it occupies the whole activity screen space).
Sample
class ProcessActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
setContent {
FlowxSdkApi.getInstance().continueProcess(
processUuid = "some process UUID string",
isModal = true,
onProcessEnded = {
// NOTE: possible processing could involve doing something in the container app (i.e. navigating to a different screen)
},
closeModalFunc = { processName ->
// NOTE: possible handling could involve doing something differently based on the `processName` value
},
).invoke()
}
}
...
}
Custom components
The container application should decide which custom component view to provide using the componentIdentifier configured in the UI designer.
A custom component receives data to populate the view and actions available to execute, as described below.
It can also be validated and provide data back into the process when executing an action.
To handle custom components, an implementation of the CustomComponentsProvider interface should be passed as a parameter when initializing the SDK:
interface CustomComponentsProvider {
fun provideCustomComposableComponent(): CustomComposableComponent?
}
Sample
class CustomComponentsProviderImpl : CustomComponentsProvider {
override fun provideCustomComposableComponent(): CustomComposableComponent? {
return object : CustomComposableComponent {...}
}
}
CustomComposableComponent
The implementation for providing a custom component is based on creating and binding a user defined @Composable function, through the CustomComposable interface:
interface CustomComposableComponent {
fun provideCustomComposable(componentIdentifier: String): CustomComposable
}
The returned CustomComposable object is an interface defined like this:
interface CustomComposable {
@Deprecated(message = "Will be removed in future releases")
val isDefined: Boolean // always MUST return `true`
// `@Composable` definitions for the custom components that can be handled
val composable: @Composable () -> Unit
/**
* Called when the data is available for the custom component
* (i.e. when the User Task that contains the custom component is displayed)
*
* @param data used to populate the custom component
*/
fun populateUi(data: Any?)
/**
* Called when the actions are available for the custom component
* (i.e. when the User Task that contains the custom component is displayed)
*
* @param actions that need to be attached to the custom component (e.g. onClick events)
*/
fun populateUi(actions: Map<String, CustomComponentAction>)
/**
* This will be called when executing an action from a FlowX.AI UI Component, when the platform needs to know if the specified/marked components are valid.
* Defaults to `true`.
*/
fun validate(): Boolean = true
/**
* This will be called when executing an action from a FlowX.AI UI Component, on computing the data to be sent as body on the network request.
* Returning `null` (i.e. default) means it does not contribute with any data to be sent.
*/
fun saveData(): JSONObject? = null
}
The value for the data parameter received in the populateUi(data: Any?) could be:
Boolean
String
java.lang.Number
org.json.JSONObject
org.json.JSONArray
The appropriate way to check and cast the data accordingly to the needs must belong to the implementation details of the custom component.
Both validation and providing data back into process are optional, and, based on the needs, it may be included in the implementation or not.
Sample
Among multiple existing custom components, there may be one that:
- allows the input of a value representing an
age
- the value should be validated (e.g. to be at least 35 years old)
- the value will be passed back into the process
class CustomComponentsProviderImpl : CustomComponentsProvider {
override fun provideCustomComposableComponent(): CustomComposableComponent? {
return object : CustomComposableComponent {
override fun provideCustomComposable(componentIdentifier: String): CustomComposable? =
when (componentIdentifier) {
"other-custom-component-identifier" -> OtherCustomComposable().invoke()
"age" -> AgeCustomComposable().invoke()
else -> null
}
}
}
}
private class AgeCustomComposable() {
operator fun invoke(): CustomComposable = object : CustomComposable {
val data: MutableStateFlow<Any?> = MutableStateFlow(null)
var actions: Map<String, CustomComponentAction> = emptyMap()
private val viewModel: AgeViewModel by lazy {
AgeViewModel(data = data,actions = actions)
}
override val isDefined: Boolean = true // always MUST return `true`
override val composable: @Composable () -> Unit =
@Composable { Age(viewModel = viewModel) }
override fun populateUi(data: Any?) {
this.data.value = data
}
override fun populateUi(actions: Map<String, CustomComponentAction>) {
this.actions = actions
}
override fun validate(): Boolean = viewModel.isValid()
override fun saveData(): JSONObject? = viewModel.buildDataToSave()
}
}
@Composable
private fun Age(
viewModel: AgeViewModel = viewModel<AgeViewModel>()
) {
val age by viewModel.ageFlow.collectAsState()
val error by viewModel.errorFlow.collectAsState()
val isError by remember(error) { mutableStateOf(error.isNotBlank()) }
Column {
OutlinedTextField(
value = age,
onValueChange = { viewModel.updateAge(it) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Age") },
)
if (isError) {
Text(
modifier = Modifier.fillMaxWidth(),
text = error,
style = TextStyle(fontSize = 12.sp),
color = Color.Red,
textAlign = TextAlign.Start,
)
}
}
}
class AgeViewModel(
private val data: MutableStateFlow<Any?> = MutableStateFlow(null),
private val actions: Map<String, CustomComponentAction> = emptyMap(),
) : ViewModel() {
private val _ageFlow = MutableStateFlow("")
val ageFlow: StateFlow<String> = _ageFlow.asStateFlow()
private val _error = MutableStateFlow("")
val errorFlow: StateFlow<String> = _error.asStateFlow()
fun updateAge(text: String) {
_ageFlow.value = text
}
fun isValid(): Boolean = ageFlow.value.toIntOrNull().let {
when {
it == null -> false.also { _error.update { "Unrecognized format" } }
it < 35 -> false.also { _error.update { "You have to be at least 35 years old" } }
else -> true.also { _error.update { "" } }
}
}
fun buildDataToSave(): JSONObject? = ageFlow.value.takeUnless { it.isBlank() }
?.let {
JSONObject(
"""
{
"app": {
"age": "$it"
}
}
""".trimIndent()
)
}
}
class OtherCustomComposable() {
operator fun invoke(): CustomComposable = object : CustomComposable {
// deprecated property: will be removed in future releases.
override val isDefined: Boolean = true // always MUST return `true`
override val composable: @Composable () -> Unit = @Composable {
/* add some @Composable implementation */
}
override fun populateUi(data: Any?) {
// extract the necessary data to be used for displaying the custom components
}
override fun populateUi(actions: Map<String, CustomComponentAction>) {
// extract the available actions that may be executed from the custom components
}
// Optional override, defaults to `true`.
// Here one can pass validation logic from viewModel (e.g. by calling `viewModel.isValid()`)
override fun validate(): Boolean = true
// Optional override, defaults to `null`.
// Here one can pass data to save from viewModel (e.g. by calling `viewModel.getDataToSave()`)
override fun saveData(): JSONObject? = null
}
}
Execute action
The custom components which the container app provides may contain FlowX actions available for execution.
These actions are received through the actions parameter of the populateUi(actions: Map<String, CustomComponentAction>) method.
In order to run an action (i.e. on a click of a button in the custom component) you need to call the executeAction method:
fun executeAction(action: CustomComponentAction, params: JSONObject? = null)
Parameters
| Name | Description | Type | Requirement |
|---|
action | Action object extracted from the actions received in the custom component | ai.flowx.android.sdk.component.custom.CustomComponentAction | Mandatory |
params | Parameters needed to execute the action | JSONObject? | Optional. It defaults to null |
Get a substitution tag value by key
fun replaceSubstitutionTag(key: String): String
All substitution tags will be retrieved by the SDK before starting the process and will be stored in memory.
Whenever the container app needs a substitution tag value for populating the UI of the custom components, it can request the substitution tag using the method above, by providing the key.
It returns:
- the key’s counterpart, if the
key is valid and found
- the empty string, if the
key is valid, but not found
- the unaltered string, if the key has the wrong format (i.e. not starting with
@@)
fun getMediaResourceUrl(key: String): String?
All media items will be retrieved by the SDK before starting the process and will be stored in memory.
Whenever the container app needs a media item url for populating the UI of the custom components, it can request the url using the method above, by providing the key.
It returns the URL string of the media resource, or null, if not found.
The container application can opt for providing a custom view in order to be used, for all the Stepper components, as a replacement for the built-in header.
The custom view receives data to populate its UI, as described below.
To provide a custom header for the Stepper, an implementation of the CustomStepperHeaderProvider interface should be passed as a parameter when initializing the SDK:
interface CustomStepperHeaderProvider {
fun provideCustomComposableStepperHeader(): CustomComposableStepperHeader?
}
Sample
class CustomStepperHeaderProviderImpl : CustomStepperHeaderProvider {
override fun provideCustomComposableStepperHeader(): CustomComposableStepperHeader? {
return object : CustomComposableStepperHeader {...}
}
}
To provide the custom header view as a @Composable function, you have to implement the CustomComposableStepperHeader interface:
interface CustomComposableStepperHeader {
fun provideComposableStepperHeader(): ComposableStepperHeader
}
The returned ComposableStepperHeader object is an interface defined like this:
interface ComposableStepperHeader {
/**
* `@Composable` definition for the custom header view
* The received argument contains the stepper header necessary data to render the view.
*/
val composable: @Composable (data: CustomStepperHeaderData) -> Unit
}
The value for the data parameter received as function argument is an interface defined like this:
interface CustomStepperHeaderData {
// title for the current step; can be empty or null
val stepTitle: String?
// title for the current selected substep; optional;
// can be empty ("") if not defined or `null` if currently there is no selected substep
val substepTitle: String?
// 1-based index of the current step
val step: Int
// total number of steps
val totalSteps: Int
// 1-based index of the current substep; can be `null` when there are no defined substeps
val substep: Int?
// total number of substeps in the current step; can be `null` or `0`
val totalSubsteps: Int?
}
Sample
override fun provideComposableStepperHeader(): ComposableStepperHeader? {
return object : ComposableStepperHeader {
override val composable: @Composable (data: CustomStepperHeaderData) -> Unit
get() = @Composable { data ->
/* add some @Composable implementation which displays `data` */
}
}
}
Collecting analytics events
To be able to collect analytics events from the SDK, an implementation for the AnalyticsCollector functional interface may be provided when initializing the SDK:
fun interface AnalyticsCollector {
fun onEvent(event: Event)
}
where the Event looks like this:
interface Event {
val type: String
val value: String
}
Sample
The implementation can be passed as a lambda, like:
analyticsCollector = { event ->
// do whatever is needed (e.g. log the event)
Log.i("Analytics", "Event(type = ${event.type}, value = ${event.value})")
}
Handling “Start of a new process”
When an action of type START_PROJECT is executed, the onNewProcessStarted lambda provided in the FlowxSdkApi.getInstance().init(...) function is invoked.
This callback provides the UUID of the newly started process, which can be used to resume the process by calling the FlowxSdkApi.getInstance().continueProcess(...) method.
It is the responsibility of the container application’s developer to implement the necessary logic for displaying the appropriate UI for the newly started process.
Sample
One way to handle this is to send a broadcast message to notify the Activity currently displaying the running process.
The Activity should handle the broadcast to reload and display the newly started process identified by processInstanceUuid (received in the broadcast intent).
FlowxSdkApi.getInstance().init(
...
onNewProcessStarted = { processInstanceUuid ->
applicationContext.sendBroadcast(
Intent("some.intent.filter.indentifier").apply {
putExtra("processInstanceUuid", processInstanceUuid)
setPackage("your.application.package")
}
)
}
...
)
class ProcessActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
setContent {
val yourBroadcastReceiver = remember {
YourBroadcastReceiver(handler = { processInstanceUuid -> /* do your own logic to refresh `ProcessContent()` */ })
}
val context = LocalContext.current
LifecycleStartEffect(true) {
ContextCompat.registerReceiver(context.applicationContext, yourBroadcastReceiver, IntentFilter("some.intent.filter.indentifier"), ContextCompat.RECEIVER_NOT_EXPORTED)
onStopOrDispose {
runCatching {
context.applicationContext.unregisterReceiver(yourBroadcastReceiver)
}
}
}
ProcessContent()
}
}
@Composable
private fun ProcessContent(...) { ... }
}
class YourBroadcastReceiver(private val handler: (String) -> Unit) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.extras?.getString("processInstanceUuid")?.let { processUuid -> handler.invoke(processUuid) }
}
}
Known issues