diff --git a/README.md b/README.md index 84f1d6d..5ded035 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ android { * [Basics concepts](./documentation/BASIC_CONCEPTS.md) * [Configuration](./documentation/CONFIGURATION.md) * [Tracking](./documentation/TRACKING.md) - * [Tracking Campaigns(Android App Links/Universal links)](./documentation/LINKING.md) - TODO: Not supported yet! + * [Tracking Campaigns(Android App Links/Universal links)](./documentation/LINKING.md) * [Fetching](./documentation/FETCHING.md) * [Push notifications](./documentation/PUSH.md) * [Anonymize customer](./documentation/ANONYMIZE.md) diff --git a/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt b/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt index 78d976e..703e337 100644 --- a/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt +++ b/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt @@ -46,7 +46,7 @@ class ExponeaPlugin : FlutterPlugin, ActivityAware { private const val STREAM_NAME_RECEIVED_PUSH = "$CHANNEL_NAME/received_push" fun handleCampaignIntent(intent: Intent?, context: Context) { - // TODO-EXF-8 : Exponea.handleCampaignIntent(intent, context) + Exponea.handleCampaignIntent(intent, context) } } @@ -240,13 +240,18 @@ private class ExponeaMethodHandler(private val context: Context) : MethodCallHan } } - private fun configure(args: Any?, result: Result) = runWithNoResult(result) { - requireNotConfigured() + private fun configure(args: Any?, result: Result) = runWithResult(result) { + try { + requireNotConfigured() + } catch (e: Exception) { + return@runWithResult false + } val data = args as Map val configuration = ExponeaConfigurationParser().parseConfig(data) Exponea.init(activity ?: context, configuration) this.configuration = configuration Exponea.notificationDataCallback = { ReceivedPushStreamHandler.handle(ReceivedPush(it)) } + return@runWithResult true } private fun isConfigured(result: Result) = runWithResult(result) { diff --git a/documentation/LINKING.md b/documentation/LINKING.md new file mode 100644 index 0000000..505402b --- /dev/null +++ b/documentation/LINKING.md @@ -0,0 +1,53 @@ +# Tracking Campaigns (Android App Links/Universal links) +The official [Flutter documentation](https://flutter.dev/docs/development/ui/navigation/deep-linking) describes how to set up your application and how to process incoming link, we just need to add tracking to Exponea. +> When the application is opened by App Link/Universal link and there is no session active, started session will contain tracking parameters from the link. + +WARNING: The official deep-linking support in flutter sdk does not currently work properly for iOS. +(Github issue: https://github.com/flutter/flutter/issues/82550) +If the issue is still not fixed in your flutter version, then usage of flutter plugin [uni_links](https://pub.dev/packages/uni_links) is recommended. +See exponea example app code and [installation steps](https://pub.dev/packages/uni_links) for more info. + +## Android +Android linking works automagically without any changes required. To enable Exponea tracking you need to add 2 methods to the `MainActivity` that will respond to incoming intents. +```kotlin +package com.exponea.example + +import android.content.Intent +import android.os.Bundle +import com.exponea.ExponeaPlugin +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Add this call: + ExponeaPlugin.Companion.handleCampaignIntent(intent, applicationContext) + super.onCreate(savedInstanceState) + } + + override fun onNewIntent(intent: Intent) { + // Add this call: + ExponeaPlugin.Companion.handleCampaignIntent(intent, applicationContext) + super.onNewIntent(intent) + } +} +``` + +## iOS +Linking requires you to call `SwiftExponeaPlugin.continueUserActivity(userActivity)` function in your `AppDelegate`. + +### With ExponeaFlutterAppDelegate +If your `AppDelegate` already extends `ExponeaFlutterAppDelegate` no change is required. + + +### Without ExponeaFlutterAppDelegate +If you don't use the `ExponeaFlutterAppDelegate`, you need to implement the method and call `SwiftExponeaPlugin`. +```swift + open override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + SwiftExponeaPlugin.continueUserActivity(userActivity) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) + } +``` diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 3fb0a0e..6ea6c02 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,27 @@ + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt index 0438474..36d2bd6 100644 --- a/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt +++ b/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt @@ -7,12 +7,12 @@ import io.flutter.embedding.android.FlutterActivity class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { - // TODO-EXF-8 ExponeaPlugin.Companion.handleCampaignIntent(intent, applicationContext) + ExponeaPlugin.Companion.handleCampaignIntent(intent, applicationContext) super.onCreate(savedInstanceState) } override fun onNewIntent(intent: Intent) { - // TODO-EXF-8 ExponeaPlugin.Companion.handleCampaignIntent(intent, applicationContext) + ExponeaPlugin.Companion.handleCampaignIntent(intent, applicationContext) super.onNewIntent(intent) } } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 0ed3828..688d3d7 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -11,6 +11,8 @@ PODS: - Quick (4.0.0) - shared_preferences (0.0.1): - Flutter + - uni_links (0.0.1): + - Flutter DEPENDENCIES: - exponea (from `.symlinks/plugins/exponea/ios`) @@ -19,6 +21,7 @@ DEPENDENCIES: - Nimble (= 8.0.7) - Quick (= 4.0.0) - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) + - uni_links (from `.symlinks/plugins/uni_links/ios`) SPEC REPOS: trunk: @@ -35,6 +38,8 @@ EXTERNAL SOURCES: :path: Flutter shared_preferences: :path: ".symlinks/plugins/shared_preferences/ios" + uni_links: + :path: ".symlinks/plugins/uni_links/ios" SPEC CHECKSUMS: AnyCodable-FlightSchool: d27283db6e9feaddb0fa73a25835e5cdf41b3213 @@ -45,6 +50,7 @@ SPEC CHECKSUMS: Nimble: a73af6ecd4c9106f434f3d55fc54570be3739e0b Quick: 6473349e43b9271a8d43839d9ba1c442ed1b7ac4 shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d + uni_links: d97da20c7701486ba192624d99bffaaffcfc298a PODFILE CHECKSUM: 7d1d5194d74e70f57aba3a9a46925dbdf19e6d3e diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 0c56f2c..bc35ad9 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -18,17 +18,6 @@ APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - exponea - - - CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS diff --git a/example/ios/Runner/Runner.entitlements b/example/ios/Runner/Runner.entitlements index 896d62d..f30899f 100644 --- a/example/ios/Runner/Runner.entitlements +++ b/example/ios/Runner/Runner.entitlements @@ -8,6 +8,7 @@ webcredentials:panaxeo.com applinks:panaxeo.com + applinks:old.panaxeo.com com.apple.security.application-groups diff --git a/example/lib/main.dart b/example/lib/main.dart index aa229c9..f70c1f0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,16 +1,65 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; -import 'page/config.dart'; -import 'page/home.dart'; +import 'package:exponea_example/page/config.dart'; +import 'package:exponea_example/page/home.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:uni_links/uni_links.dart'; void main() { runApp(MyApp()); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State { + final _scaffoldMessengerKey = GlobalKey(); + StreamSubscription? _linkSub; + + @override + void initState() { + _handleInitialLink(); + _handleIncomingLinks(); + super.initState(); + } + + Future _handleInitialLink() async { + try { + final initialLink = await getInitialLink(); + if (initialLink != null) { + _showSnackBarMessage('App opened with link: $initialLink'); + } + } on PlatformException catch (err) { + print('initialLink: $err'); + } + } + + void _handleIncomingLinks() { + _linkSub = linkStream.listen((String? link) { + _showSnackBarMessage('App resumed with link: $link'); + }, onError: (err) { + _showSnackBarMessage('App resume with link failed: $err'); + }); + } + + void _showSnackBarMessage(String text) { + final snackBar = SnackBar(content: Text(text)); + _scaffoldMessengerKey.currentState!.showSnackBar(snackBar); + } + + @override + void dispose() { + _linkSub?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MaterialApp( + scaffoldMessengerKey: _scaffoldMessengerKey, theme: ThemeData.from( colorScheme: ColorScheme.light( primary: Colors.amber, @@ -29,6 +78,7 @@ class MyApp extends StatelessWidget { }, ), ), + builder: (context, child) => child!, ); } } diff --git a/example/lib/page/config.dart b/example/lib/page/config.dart index a9e4283..d9db6b2 100644 --- a/example/lib/page/config.dart +++ b/example/lib/page/config.dart @@ -156,7 +156,13 @@ class _ConfigPageState extends State { ), ); try { - await _plugin.configure(config); + final configured = await _plugin.configure(config); + if (!configured) { + final snackBar = SnackBar( + content: Text('SDK was already configured'), + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } widget.doneCallback.call(config); } on PlatformException catch (err) { final snackBar = SnackBar( diff --git a/example/lib/page/link.dart b/example/lib/page/link.dart new file mode 100644 index 0000000..66cf5d6 --- /dev/null +++ b/example/lib/page/link.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class LinkPage extends StatelessWidget { + final String link; + + const LinkPage({ + Key? key, + required this.link, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Link'), + ), + body: Center( + child: Text(link), + ), + floatingActionButton: FloatingActionButton( + child: Icon(Icons.done), + onPressed: () => Navigator.of(context).pop(), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 894b1d5..374fc97 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -259,6 +259,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + uni_links: + dependency: "direct main" + description: + name: uni_links + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.1" + uni_links_platform_interface: + dependency: transitive + description: + name: uni_links_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + uni_links_web: + dependency: transitive + description: + name: uni_links_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" vector_math: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ae17f08..c80c486 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: path: ../ shared_preferences: 2.0.6 + uni_links: 0.5.1 cupertino_icons: ^1.0.3 dev_dependencies: diff --git a/ios/Classes/ExponeaFlutterAppDelegate.swift b/ios/Classes/ExponeaFlutterAppDelegate.swift index 91f0013..72b1709 100644 --- a/ios/Classes/ExponeaFlutterAppDelegate.swift +++ b/ios/Classes/ExponeaFlutterAppDelegate.swift @@ -74,4 +74,13 @@ open class ExponeaFlutterAppDelegate: FlutterAppDelegate { completionHandler([.alert]) } } + + open override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + SwiftExponeaPlugin.continueUserActivity(userActivity) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) + } } diff --git a/ios/Classes/SwiftExponeaPlugin.swift b/ios/Classes/SwiftExponeaPlugin.swift index 28b9c61..88ee18f 100644 --- a/ios/Classes/SwiftExponeaPlugin.swift +++ b/ios/Classes/SwiftExponeaPlugin.swift @@ -105,7 +105,10 @@ public class SwiftExponeaPlugin: NSObject, FlutterPlugin { } private func configure(_ args: Any?, with result: FlutterResult) { - guard requireNotConfigured(with: result) else { return } + guard !exponeaInstance.isConfigured else { + result(false) + return + } do { let data = args as! [String:Any?] @@ -121,7 +124,7 @@ public class SwiftExponeaPlugin: NSObject, FlutterPlugin { flushingSetup: config.flushingSetup ) exponeaInstance.pushNotificationsDelegate = self - result(nil) + result(true) } catch { let error = FlutterError(code: errorCode, message: error.localizedDescription, details: nil) result(error) @@ -421,6 +424,14 @@ extension SwiftExponeaPlugin: PushNotificationManagerDelegate { public static func handlePushNotificationOpened(response: UNNotificationResponse) { ExponeaSDK.Exponea.shared.handlePushNotificationOpened(response: response) } + + @objc + static func continueUserActivity(_ userActivity: NSUserActivity) { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let incomingURL = userActivity.webpageURL + else { return } + ExponeaSDK.Exponea.shared.trackCampaignClick(url: incomingURL, timestamp: nil) + } } extension PushAction { diff --git a/lib/src/interface.dart b/lib/src/interface.dart index d793319..50c29d4 100644 --- a/lib/src/interface.dart +++ b/lib/src/interface.dart @@ -16,7 +16,8 @@ abstract class BaseInterface { /// Configure Exponea SDK. /// Should only be called once. /// You need to configure ExponeaSDK before calling most methods. - Future configure(ExponeaConfiguration configuration); + /// Returns true if configuration was successful. Returns false if sdk was already configured. + Future configure(ExponeaConfiguration configuration); /// Check whether Exponea SDK is configured. Future isConfigured(); diff --git a/lib/src/platform/method_channel.dart b/lib/src/platform/method_channel.dart index 14b0bcb..fcc32c1 100644 --- a/lib/src/platform/method_channel.dart +++ b/lib/src/platform/method_channel.dart @@ -53,9 +53,9 @@ class MethodChannelExponeaPlatform extends ExponeaPlatform { Stream? _receivedPushStream; @override - Future configure(ExponeaConfiguration configuration) async { + Future configure(ExponeaConfiguration configuration) async { final data = ExponeaConfigurationEncoder.encode(configuration); - await _channel.invokeMethod(_methodConfigure, data); + return (await _channel.invokeMethod(_methodConfigure, data))!; } @override diff --git a/lib/src/platform/platform_interface.dart b/lib/src/platform/platform_interface.dart index 99ecf48..b2a1d52 100644 --- a/lib/src/platform/platform_interface.dart +++ b/lib/src/platform/platform_interface.dart @@ -53,7 +53,7 @@ abstract class ExponeaPlatform extends PlatformInterface } @override - Future configure(ExponeaConfiguration configuration) async { + Future configure(ExponeaConfiguration configuration) async { throw UnimplementedError(); } diff --git a/lib/src/plugin/exponea.dart b/lib/src/plugin/exponea.dart index 3bfeb39..7d53dcf 100644 --- a/lib/src/plugin/exponea.dart +++ b/lib/src/plugin/exponea.dart @@ -25,7 +25,7 @@ class ExponeaPlugin implements BaseInterface { Future checkPushSetup() => _platform.checkPushSetup(); @override - Future configure(ExponeaConfiguration configuration) => + Future configure(ExponeaConfiguration configuration) => _platform.configure(configuration); @override