Skip to content

Commit

Permalink
[MOBILE-2196] Support handling background notifications (#123)
Browse files Browse the repository at this point in the history
  • Loading branch information
jyaganeh authored Apr 4, 2022
1 parent abf5388 commit 9c1627f
Show file tree
Hide file tree
Showing 10 changed files with 417 additions and 48 deletions.
70 changes: 46 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ The Airship Flutter plugin allows using Airship's native iOS and Android APIs wi

1. Add the airship_flutter dependency to your package's pubspec.yaml file:

```
```yaml
dependencies:
airship_flutter: ^4.0.0
airship_flutter: ^5.0.0
```
2. Install your flutter package dependencies by running the following in the command line at your project's root directory:
```
```sh
$ flutter pub get
```

3. Import airship into your project:

```
```dart
import 'package:airship_flutter/airship_flutter.dart';
```

Expand All @@ -30,39 +30,61 @@ import 'package:airship_flutter/airship_flutter.dart';
2) Apply the `com.google.gms.google-services` plugin to the android/app.

3) Create a new `airshipconfig.properties` file with your application’s settings and
place it inside the android/app/src/main/assets directory:
place it inside the `android/app/src/main/assets` directory:

```
developmentAppKey = Your Development App Key
developmentAppSecret = Your Development App Secret
```properties
developmentAppKey = Your Development App Key
developmentAppSecret = Your Development App Secret

productionAppKey = Your Production App Key
productionAppSecret = Your Production Secret

# Toggles between the development and production app credentials
# Before submitting your application to an app store set to true
inProduction = false

# LogLevel is "VERBOSE", "DEBUG", "INFO", "WARN", "ERROR" or "ASSERT"
developmentLogLevel = DEBUG
productionLogLevel = ERROR

productionAppKey = Your Production App Key
productionAppSecret = Your Production Secret
# Notification customization
notificationIcon = ic_notification
notificationAccentColor = #ff0000

# Toggles between the development and production app credentials
# Before submitting your application to an app store set to true
inProduction = false
# Optional - Set the default channel
notificationChannel = customChannel
```

# LogLevel is "VERBOSE", "DEBUG", "INFO", "WARN", "ERROR" or "ASSERT"
developmentLogLevel = DEBUG
productionLogLevel = ERROR
4) Optional. In order to be notified of messages that are sent to the device while the app is in the background,
define a `BackgroundMessageHandler` as a top-level function in your app's main.dart file:

# Notification customization
notificationIcon = ic_notification
notificationAccentColor = #ff0000
```dart
Future<void> backgroundMessageHandler(
Map<String, dynamic> payload,
Notification? notification) async {
// Handle the message
print("Received background message: $payload, $notification");
}
# Optional - Set the default channel
notificationChannel = customChannel
void main() {
Airship.setBackgroundMessageHandler(backgroundMessageHandler);
runApp(MyApp());
}
```

The handler is executed in a separate background isolate outside the application's main isolate,
so it is not possible to interact with the application UI or update application state from the handler.
Care should be taken to ensure the handler logic completes as soon as possible in order to avoid
ANRs (Application Not Responding), which may trigger the OS to automatically terminate the process.

### iOS Setup

1) Add the following capabilities for your application target:
- Push Notification
- Background Modes > Remote Notifications

2) Create a plist `AirshipConfig.plist` and include it in your application’s target:
```
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
Expand All @@ -85,7 +107,7 @@ video, you will need to create a notification service extension by following the

### Example Usage

```
```dart
// Import package
import 'package:airship_flutter/airship.dart';
Expand Down Expand Up @@ -123,7 +145,7 @@ Note that Hybrid Composition can be enabled on Android by setting InboxMessageVi

#### Example Usage

```
```dart
// Import package
import 'package:airship_flutter/airship.dart';
Expand Down
6 changes: 6 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ android {

}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
}

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_version"
Expand Down
1 change: 0 additions & 1 deletion android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@
<application>
<meta-data android:name="com.urbanairship.autopilot"
android:value="com.airship.flutter.FlutterAutopilot"/>

</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package com.airship.flutter

import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import androidx.core.content.edit
import com.urbanairship.json.JsonValue
import com.urbanairship.push.NotificationInfo
import com.urbanairship.push.PushMessage
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.embedding.engine.dart.DartExecutor.DartCallback
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.view.FlutterCallbackInformation.lookupCallbackInformation
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Collections
import java.util.concurrent.atomic.AtomicBoolean

class AirshipBackgroundExecutor(
private val appContext: Context,
private val sharedPrefs: SharedPreferences = appContext.getAirshipSharedPrefs()
) : MethodCallHandler {
private val isIsolateStarted: AtomicBoolean = AtomicBoolean(false)

private var methodChannel: MethodChannel? = null
private var flutterEngine: FlutterEngine? = null

private val mainHandler by lazy {
Handler(appContext.mainLooper)
}

private val messageCallback: Long
get() = sharedPrefs.getLong(MESSAGE_CALLBACK, 0)

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) =
when (call.method) {
"backgroundIsolateStarted" -> {
result.success(true)
onIsolateStarted()
}
else -> result.notImplemented()
}

fun startIsolate(callback: Long, args: FlutterShellArgs?) {
if (flutterEngine != null) return

val loader = FlutterLoader()
mainHandler.post {
loader.startInitialization(appContext)
loader.ensureInitializationCompleteAsync(appContext, null, mainHandler) {
if (!isIsolateStarted.get()) {
val engine = FlutterEngine(appContext, args?.toArray())
.also { flutterEngine = it }

methodChannel =
MethodChannel(engine.dartExecutor, BACKGROUND_CHANNEL).apply {
setMethodCallHandler(this@AirshipBackgroundExecutor)
}

val callbackInfo = lookupCallbackInformation(callback)
engine.dartExecutor.executeDartCallback(
DartCallback(appContext.assets, loader.findAppBundlePath(), callbackInfo)
)
}
}
}
}

private fun onIsolateStarted() {
isIsolateStarted.set(true)

// Notify the background message callback with any messages that were queued up while the
// isolate was starting.
synchronized(messageQueue) {
for ((message, notificationInfo) in messageQueue) {
handleBackgroundMessage(appContext, message, notificationInfo)
}
messageQueue.clear()
}
}

val isReady: Boolean
get() = isIsolateStarted.get()

@OptIn(ExperimentalCoroutinesApi::class)
private fun executeDartCallbackInBackgroundIsolate(
pushMessage: PushMessage,
notificationInfo: NotificationInfo? = null
) = callbackFlow<Unit> {
if (flutterEngine == null) {
trySend(Unit)
channel.close()
return@callbackFlow
}

val result: MethodChannel.Result = object : MethodChannel.Result {
override fun success(result: Any?) {
trySend(Unit)
channel.close()
}

override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) {
trySend(Unit)
channel.close()
}

override fun notImplemented() {
trySend(Unit)
channel.close()
}
}

val eventData = notificationInfo?.eventData()?.toJsonValue() ?: JsonValue.NULL
val args = mapOf(
"messageCallback" to messageCallback,
"payload" to pushMessage.toJsonValue().toString(),
"notification" to eventData.toString()
)

mainHandler.post {
methodChannel?.invokeMethod("onBackgroundMessage", args, result) ?: run {
trySend(Unit)
channel.close()
}
}

awaitClose()
}

companion object {
private const val TAG = "airship"
private const val BACKGROUND_CHANNEL = "com.airship.flutter/airship_background"
private const val ISOLATE_CALLBACK = "isolate_callback"
private const val MESSAGE_CALLBACK = "message_callback"

private val messageQueue =
Collections.synchronizedList(mutableListOf<Pair<PushMessage, NotificationInfo?>>())

@Volatile
internal var instance: AirshipBackgroundExecutor? = null
private set

internal fun startIsolate(context: Context, shellArgs: FlutterShellArgs? = null) {
val callback = context.getAirshipSharedPrefs().getLong(ISOLATE_CALLBACK, 0)
if (instance?.isReady == true || callback == 0L) return

startIsolate(context, callback, shellArgs)
}

private fun startIsolate(
context: Context,
callbackHandle: Long,
shellArgs: FlutterShellArgs?
) {
if (instance != null) return
synchronized(this) {
if (instance != null) return
instance = AirshipBackgroundExecutor(context).apply {
startIsolate(callbackHandle, shellArgs)
}
}
}

internal fun setCallbacks(context: Context, isolateCallback: Long, messageCallback: Long) {
context.getAirshipSharedPrefs().edit {
putLong(ISOLATE_CALLBACK, isolateCallback)
putLong(MESSAGE_CALLBACK, messageCallback)
}
}

internal fun handleBackgroundMessage(
context: Context,
pushMessage: PushMessage,
notificationInfo: NotificationInfo? = null
) {
if (!hasMessageCallback(context)) return

val executor = instance
if (executor?.isReady == true) {
// Send the message to the registered handler callback via the background isolate.
GlobalScope.launch {
executor.executeDartCallbackInBackgroundIsolate(pushMessage, notificationInfo).first()
}
} else {
// Isolate not ready. Queue the message for later.
messageQueue.add(pushMessage to notificationInfo)
}
}

private fun hasMessageCallback(context: Context): Boolean =
context.getAirshipSharedPrefs().getLong(MESSAGE_CALLBACK, 0) != 0L
}
}
Loading

0 comments on commit 9c1627f

Please sign in to comment.