Developer Documentation

Welcome to the developer documentation. The necessary guidelines and resources for developing Parabox plugins are included here.

Plug-ins are one of the core concepts of Parabox, they can provide Parabox with a variety of sources of information. Parabox itself does not provide information sources, but implements these functions through plug-ins.

Development environment

Development Tools

Android Studio Chipmunk | 2021.2.1 or later

Development Language

Start with template project

We provide a template project. The template project contains the SDK and completes the basic schema setup. You can quickly start developing your plugin from it.

Download template project

Use the following command to clone the GitHubopen in new window repository:

$ git clone https://github.com/Parabox-App/parabox-extension-example.git

Or download the zip file: Downloadopen in new window

Architecture overview

The following shows the directory structure under app/src/main:

main:
│  AndroidManifest.xml
├─java
│  └─com
│      └─parabox
│          └─example
│              │  MainActivity.kt                // *Core Activity
│              ├─core
│              │  │  HiltApplication.kt          // Dagger-hilt
│              │  └─util
│              │          DataStore.kt           // DataStore to store plugin settings
│              │          NotificationUtil.kt    // Foreground Service Notification
│              ├─domain
│              │  ├─service
│              │  │      ConnService.kt          // *Core Service
│              │  └─util
│              │          CustomKey.kt           // Constants used by the connector
│              │          ServiceStatus.kt       // Service status encapsulation for front-end display
│              └─ui
│                  ├─main
│                  │      MainScreen.kt          // Composable
│                  │      MainViewModel.kt       // ViewModel
│                  ├─theme
│                  │      Color.kt
│                  │      Theme.kt
│                  │      Type.kt
│                  └─util
│                          Preference.kt         // Preference Composable
└─res  
    ├─values
    │      colors.xml
    │      strings.xml                           // String Resource
    │      themes.xml
    └─xml
            backup_rules.xml
            data_extraction_rules.xml
            

Note: ConnService path and naming are strictly limited, please do not move or rename.

Plugin functionality is undertaken by MainActivity (inherited from ParaboxActivity) and ConnService (inherited from ParaboxService).

MainActivity is used to display the main interface of the plugin and provide users with interactive control over the service. The interface is built using Compose.

ConnService plays the role of the server, on the one hand, it is bound to the Parabox background service and undertakes the task of communicating with the master. On the other hand, it is bound to MainActivity to provide running status updates to the main interface. This means that any communication between MainActivity and Parabox must go through it. It is also the core unit for receiving and sending messages from various platforms.

To reduce complexity and aid understanding, this project only references necessary dependencies. If you need navigation, data persistence or more, you can add Navigation, Room, etc.

dependenciesuses
Parabox Development KitParabox Extension Development Kit
Hiltopen in new windowDependency Injection Library
DataStoreopen in new windowKey-value store
ViewModelopen in new windowArchitecture

Configuration

In this template project, the necessary changes you need to make are marked with // TODO. A little trick is to open the TODO window in Android Studio and go through each TODO comment in turn and make changes.

1. Click on TODO 1 in the TODO window, located in build.gradle. Replace com.parabox.example with your package name. You need to use the Rename function to change the directory tree name synchronously.

android {
//    TODO 1 : Set your extension's Package Name.
    namespace 'com.android.myextension'
    compileSdk 33

    defaultConfig {
        applicationId "com.android.myextension"
        minSdk 26
        targetSdk 33
        versionCode 1
        versionName "1.0"

        ...
    }
    ...
}

2. Click TODO 2 in the TODO window, which is located in AndroidManifest.xml. Update the connection_type and connection_name values. And update android:label to your plugin name.

connection_type needs to be filled with Int type. This value is used to distinguish different plug-ins. Please ensure that this value is unique among the installed plug-ins.

connection_name needs to be filled in String type. This value is used to display the plugin name on the main app.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    ...

    <application
        ...

        android:label="@string/my_app_name"
        ...
        >

        <!-- TODO 2: Set your extension's Type and Name here.-->
        <meta-data android:name="connection_type" android:value="1234"/>
        <meta-data android:name="connection_name" android:value="Extension"/>
        ...
    </application>
</manifest>

3. Click TODO 2-1 in the TODO window, which is located in AndroidManifest.xml. Configure basic information (profile, developer, etc.) for your plugin. At the same time, the numbers 0, 1 and 2 describe the plugin's support for six basic message contents. 0 means no support, 1 means receive only, 2 means full support (receive and send). The configuration will be displayed on the Extensions settings page. (Added in v.1.0.5-beta)

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    ...

    <application
        ...
        <!-- TODO 2-1: Configure basic information for your extension-->
        <meta-data android:name="author" android:value="Parabox"/>
        <meta-data android:name="description" android:value="Parabox Extension Example"/>
        <meta-data android:name="plain_text_support" android:value="1"/>
        <meta-data android:name="image_support" android:value="0"/>
        <meta-data android:name="audio_support" android:value="0"/>
        <meta-data android:name="file_support" android:value="0"/>
        <meta-data android:name="at_support" android:value="0"/>
        <meta-data android:name="quote_reply_support" android:value="0"/>
        ...
    </application>
</manifest>

4. Compile and install the app to your test device. If all goes well, your plugin should be discoverable and displayed in the main app. The plug-in information will be displayed in the status detection dialog of the home page and the extension category of the settings page (if not, try restarting the main app). Try to start the service, the service status displayed on the main app will be updated in time.

Click "Send a test message" in the test area, and the main app should receive the message from the plugin. After the master responds, the text of the reply message should pop up with Snackbar on the interface of the plugin. The mechanism of operation here will be explained later.

Development Guidelines

Click on TODO 3 in the TODO window, which is located in ConnService.kt. In the onStartParabox method, delete the sample implementation and write your own service startup code. As the example implementation demonstrates, you can use the updateServiceState method to update the service state while startup is in progress. Optional service states include STOP , PAUSE , ERROR , LOADING , RUNNING. Status updates will be instantly reflected to the front end.

lifecycleScope.launch {

// TODO 3: Delete the code below, then write your own startup process
    updateServiceState(ParaboxKey.STATE_LOADING, "Step A")
    delay(1000)
    updateServiceState(ParaboxKey.STATE_LOADING, "Step B")
    delay(1000)
    updateServiceState(ParaboxKey.STATE_PAUSE, "Step C")
    delay(1000)
    updateServiceState(ParaboxKey.STATE_LOADING, "Step D")
    delay(1000)
    updateServiceState(ParaboxKey.STATE_RUNNING, "Step E")
}

Then choose to implement some of other abstract methods such as customHandleMessage, onMainAppLaunch, onRecallMessage according to your needs. For details, please refer to the development kit documentation.

Communication Mechanism

The Parabox plug-in communication mechanism is based on Messenger, and the development kit has carried out the necessary encapsulation. According to the communication object, whether or not to send back authentication is divided into three categories: Request, COMMAND and NOTIFICATION.

TypeSend WithRespond WithSenderResponder
Request (Request)sendRequestsendRequestResponseParaboxServiceParaboxActivity or master
command(COMMAND)sendCommandsendCommandResponseParaboxActivity or masterParaboxService
Notification (NOTIFICATION)sendNotification-ParaboxActivity, ParaboxService, or master-

Request, COMMAND have their own loopback authentication and timeout mechanisms. It is guaranteed that each communication must trigger the onResult callback within the timeout period. The returned ParaboxResult carries the data returned by the request successfully or the error code returned by the request failure. Often used for logic that needs to be sure to get a reply before proceeding. Such as message sending/receiving, updating configuration, etc.

The internal implementation of Request and COMMAND uses Kotlin coroutines, such as using [CompletableDeferred](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core /kotlinx.coroutines/-completable-deferred/) implements pending wait. If the callback method is not suitable for your development needs, you can also rewrite it in the form of a suspending function through simple encapsulation:

suspend fun sendRequest(
    request: Int,
    client: Int,
    extra: Bundle,
    timeoutMillis: Long,
) : ParaboxResult {
    return suspendCoroutine<ParaboxResult> { cont ->
        sendRequest(
            request = request,
            client = client,
            extra = extra,
            timeoutMillis = timeoutMillis,
        ) {
            if (it is ParaboxResult.Success) {
                cont.resume(it)
            } else {
                val errorCode = (it as ParaboxResult.Fail).errorCode
                cont.resumeWithException(Exception("$errorCode"))
            }
        }
    }
}

The transmission method and processing logic of Request and COMMAND are basically the same, only the sender and the responder differ. sendRequest also needs to provide an additional target parameter. Since there is usually a host and ParaboxActivity connected to ParaboxService at the same time, it can be specified by passing CLIENT_MAIN_APP or CLIENT_CONTROLLER for the client parameter.

NOTIFICATION is initiated by either party and does not require a reply. There is no guarantee that the recipient will receive it successfully. Often used for logic that is sent frequently and does not require a reply. Such as logs, status updates, etc.

To help you quickly understand the communication mechanism of the plug-in, this project provides two simple examples: send a message to Parabox from the main interface of the plug-in (this is not common, the message is usually received from the Service and then delivered to the client), And send a message from Parabox to the main interface of the plug-in (the result here is a Toast popping up), the example shows two different forms of transmission and transmission in different directions.

Communication that requires loopback authentication (take command as an example)

1. Click TODO 4 in the TODO window and declare an Int constant that identifies the command. In this case COMMAND_RECEIVE_TEST_MESSAGE in CustomKey.

object CustomKey {
    // TODO 4: Added static Key constants for commands
    const val COMMAND_RECEIVE_TEST_MESSAGE = 9999
}

2. Click on TODO 5 in the TODO window, browse the receiveTestMessage implementation, and learn how to use sendCommand to pass in command constants, carry extra data, and send commands.

fun receiveTestMessage() {
    // TODO 5 : Call sendCommand function with COMMAND_RECEIVE_TEST_MESSAGE
    sendCommand(
        command = CustomKey.COMMAND_RECEIVE_TEST_MESSAGE,
        extra = Bundle().apply {
            putString("content", "...")
        },
        timeoutMillis = 3000,
        onResult = {
            ...
        })
    }

3. Click TODO 6 in the TODO window, browse the customHandleMessage implementation, and learn how to use msg.what to identify the command type and call the corresponding method. Additional data to carry is available from msg.obj. To call sendCommandResponse later, pass in metadata as a parameter.

override fun customHandleMessage(msg: Message, metadata: ParaboxMetadata) {
    when (msg.what) {
        // TODO 6: Handle custom command
        CustomKey.COMMAND_RECEIVE_TEST_MESSAGE -> {
            receiveTestMessage(msg, metadata)
        }
    }
}

4. Click on TODO 7 in the TODO window and browse the receiveTestMessage implementation to learn how to use sendCommandResponse to send back the command result. If sendCommandResponse is not called, the timeout mechanism of the original command will be triggered and ParaboxResult with ERROR_TIMEOUT will be returned.

private fun receiveTestMessage(msg: Message, metadata: ParaboxMetadata) {

    ...
    // TODO 7 : Call sendCommandResponse when the job is done
    if (it is ParaboxResult.Success) {
        sendCommandResponse(
            isSuccess = true,
            metadata = metadata,
            extra = Bundle().apply {
                putString(
                    "message",
                    "Message received at ${System.currentTimeMillis()}"
                )
            }
        )
    } else {
        sendCommandResponse(
            isSuccess = false,
            metadata = metadata,
            errorCode = (it as ParaboxResult.Fail).errorCode
        )
    }
}

5. After finishing the above process, delete the sample code.

One-way one-way communication (take notification as an example)

1. Click TODO 8 in the TODO window and declare an Int constant that identifies the command. In this case, NOTIFICATION_SHOW_TEST_MESSAGE_SNACKBAR in CustomKey.

object CustomKey {
    // TODO 8: Added static Key constants for notifications
    const val NOTIFICATION_SHOW_TEST_MESSAGE_SNACKBAR = 9998
}

2. Click on TODO 9 in the TODO window, browse the showTestMessageSnackbar implementation, and learn how to use sendNotification to pass in notification constants, carry extra data, and send notifications.

// TODO 9 : Call sendNotification function with NOTIFICATION_SHOW_TEST_MESSAGE_SNACKBAR
private fun showTestMessageSnackbar(message: String) {
    sendNotification(CustomKey.NOTIFICATION_SHOW_TEST_MESSAGE_SNACKBAR, Bundle().apply {
        putString("message", message)
    })
}

3. Click TODO 10 in the TODO window, browse the customHandleMessage implementation, and learn how to use msg.what to identify the notification type and call the corresponding method. Additional data to carry is available from msg.obj. Since there is no loopback, there is no need to pass in metadata.

override fun customHandleMessage(msg: Message, metadata: ParaboxMetadata) {
    when(msg.what){
        // TODO 10: Handle custom notification
        CustomKey.NOTIFICATION_SHOW_TEST_MESSAGE_SNACKBAR -> {
            (msg.obj as Bundle).getString("message")?.also {
                showTestMessageSnackbar(it)
            }
        }
    }
}

4. After finishing the above process, delete the sample code.

And for common communication use cases, the SDK has been packaged into easy-to-call methods. Its internal implementation still uses REQUEST , COMMAND and NOTIFICATION. Please refer to Common Use Cases.

Common Use Cases

Receive message

Receive message takes the Parabox main side as the perspective, that is, the process that the plug-in receives the message from the message source and delivers it to the main side. The SDK provides the receiveMessage method for this use case. The key to using this method is to generate an instance of ReceiveMessageDto.

parametersdescription
List ofcontents
Instance ofprofile
Instance ofsubjectProfile
timestampmessage reception time timestamp
messageIdneeds to be unique to uniquely identify the message. It is allowed to be empty, and the database will automatically assign an id to it, but emptying it will cause message recall, cache mechanism, etc. to fail.
pluginConnectionis used to describe the connection information of the session to which this message belongs. Please refer to the table below in combination
parametersdescription
connectionTypemust be the same as the META_DATA declaration value in AndroidManifest.xml. For the acquisition method, please refer to the sample project.
sendTargetTypeDescribes the current session type. Optional SendTargetType.USER or SendTargetType.GROUP
idis used to uniquely identify the current session. It needs to be consistent with the id in the subjectProfile in the above table
private fun receiveTestMessage(msg: Message, metadata: ParaboxMetadata) {
    // TODO 11 : Receive Message
    val contactId = 1L
    val profile = Profile(
        name = "anonymous",
        avatar = "https://gravatar.loli.net/avatar/d41d8cd98f00b204e9800998ecf8427e?d=mp&v=1.5.1",
        id = contactId
    )
    receiveMessage(
        ReceiveMessageDto(
            contents = listOf(PlainText(text = "content")),
            profile = profile,
            subjectProfile = profile,
            timestamp = System.currentTimeMillis(),
            messageId = null,
            pluginConnection = PluginConnection(
                connectionType = connectionType,
                sendTargetType = SendTargetType.USER,
                id = contactId
            )
        ),
        onResult = {
            ...
        }
    )
}

The execution result of the above code should be shown as below:

message received

Send a message

The SDK provides the onSendMessage method for this use case. The key to using this method is to get the required information from the only parameter SendMessageDto.

parametersdescription
List ofcontents
timestampmessage sending time timestamp
pluginConnectionis used to describe the connection information of the session to which this message belongs. Please refer to the table above in combination
messageIdUniquely identifies this message and is generated by the system. It is recommended to temporarily save as a map to recall this message

The method is a suspend function. Returns true after confirming that the sending is complete, otherwise returns false.

Withdraw a message

The SDK provides the onRecallMessage method for this use case. Only one parameter of messageId is provided, which needs to be used together with the messageId saved when sending the message.

The method is a suspend function. After confirming that the withdrawal is successful, return true, otherwise return false. If retraction is not supported, false can also be returned directly.

From Scratch

To learn how to use the toolkit, please refer to Development Kit Documentation.

Last Updated:
Contributors: ojhdt