diff --git a/README.md b/README.md index e9da650..46232ad 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ dependencies { implementation "com.google.firebase:firebase-messaging:17.3.4" ... } + +apply plugin: 'com.google.gms.google-services' ``` #### b. Add your Batch key @@ -115,23 +117,25 @@ BatchPush.registerForRemoteNotifications(); ### Small push notification icon -It is recommended to provide a small notification icon in your `MainActivity.java`: - -```java -// push_icon.png in your res/drawable-{dpi} folder -import com.batch.android.Batch; -import android.os.Bundle; -import android.graphics.Color; - -... +For better results on Android 5.0 and higher, it is recommended to add a Small Icon and Notification Color. +An icon can be generated using Android Studio's asset generator: as it will be tinted and masked by the system, only the alpha channel matters and will define the shape displayed. It should be of 24x24dp size. +If your notifications shows up in the system statusbar in a white shape, this is what you need to configure. - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); +This can be configured in the manifest as metadata in the application tag: - Batch.Push.setSmallIconResourceId(R.drawable.push_icon); - Batch.Push.setNotificationsColor(Color.parseColor(getResources().getString(R.color.pushIconBackground))); - } +```xml + + + + + + + + ``` ### Mobile landings and in-app messaging diff --git a/android/src/main/java/tech/bam/RNBatchPush/RNBatchModule.java b/android/src/main/java/tech/bam/RNBatchPush/RNBatchModule.java index d961aa2..046cefd 100644 --- a/android/src/main/java/tech/bam/RNBatchPush/RNBatchModule.java +++ b/android/src/main/java/tech/bam/RNBatchPush/RNBatchModule.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.content.res.Resources; +import android.location.Location; import android.support.annotation.Nullable; import android.util.Log; @@ -12,6 +13,7 @@ import com.batch.android.BatchMessage; import com.batch.android.BatchUserDataEditor; import com.batch.android.Config; +import com.batch.android.json.JSONObject; import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; @@ -287,6 +289,33 @@ public void userData_save(ReadableArray actions) { editor.save(); } + @ReactMethod + public void userData_trackEvent(String name, String label, ReadableMap serializedEventData) { + Batch.User.trackEvent(name, label, RNUtils.convertSerializedEventDataToEventData(serializedEventData)); + } + + @ReactMethod + public void userData_trackTransaction(double amount, ReadableMap data) { + Batch.User.trackTransaction(amount, new JSONObject(data.toHashMap())); + } + + @ReactMethod + public void userData_trackLocation(ReadableMap serializedLocation) { + Location nativeLocation = new Location("tech.bam.RNBatchPush"); + nativeLocation.setLatitude(serializedLocation.getDouble("latitude")); + nativeLocation.setLongitude(serializedLocation.getDouble("longitude")); + + if (serializedLocation.hasKey("precision")) { + nativeLocation.setAccuracy((float) serializedLocation.getDouble("precision")); + } + + if (serializedLocation.hasKey("date")) { + nativeLocation.setTime((long) serializedLocation.getDouble("date")); + } + + Batch.User.trackLocation(nativeLocation); + } + // EVENT LISTENERS @Override diff --git a/android/src/main/java/tech/bam/RNBatchPush/RNUtils.java b/android/src/main/java/tech/bam/RNBatchPush/RNUtils.java index c492bab..3989748 100644 --- a/android/src/main/java/tech/bam/RNBatchPush/RNUtils.java +++ b/android/src/main/java/tech/bam/RNBatchPush/RNUtils.java @@ -1,5 +1,9 @@ package tech.bam.RNBatchPush; +import android.support.annotation.Nullable; +import android.util.Log; + +import com.batch.android.BatchEventData; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; @@ -65,4 +69,41 @@ public static WritableArray convertArrayToWritableArray(Object[] input) { } return output; } + + @Nullable + public static BatchEventData convertSerializedEventDataToEventData(@Nullable ReadableMap serializedEventData) { + if (serializedEventData == null) { + return null; + } + + BatchEventData batchEventData = new BatchEventData(); + ReadableArray tags = serializedEventData.getArray("tags"); + + for (int i = 0; i < tags.size(); i++) { + batchEventData.addTag(tags.getString(i)); + } + + ReadableMap attributes = serializedEventData.getMap("attributes"); + ReadableMapKeySetIterator iterator = attributes.keySetIterator(); + + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + ReadableMap valueMap = attributes.getMap(key); + + String type = valueMap.getString("type"); + if ("string".equals(type)) { + batchEventData.put(key, valueMap.getString("value")); + } else if ("boolean".equals(type)) { + batchEventData.put(key, valueMap.getBoolean("value")); + } else if ("integer".equals(type)) { + batchEventData.put(key, valueMap.getDouble("value")); + } else if ("float".equals(type)) { + batchEventData.put(key, valueMap.getDouble("value")); + } else { + Log.e("RNBatchPush", "Invalid parameter : Unknown event_data.attributes type (" + type + ")"); + } + } + + return batchEventData; + } } diff --git a/ios/RNBatch.m b/ios/RNBatch.m index 7ed3b0d..87cbe33 100644 --- a/ios/RNBatch.m +++ b/ios/RNBatch.m @@ -128,6 +128,166 @@ - (id)init { [editor save]; } +// Event tracking + +RCT_EXPORT_METHOD(userData_trackEvent:(NSString*)name label:(NSString*)label data:(NSDictionary*)serializedEventData) +{ + BatchEventData *batchEventData = nil; + + if ([serializedEventData isKindOfClass:[NSDictionary class]]) + { + batchEventData = [BatchEventData new]; + + if (![serializedEventData isKindOfClass:[NSDictionary class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data should be an object or null"); + return; + } + + NSArray* tags = serializedEventData[@"tags"]; + NSDictionary* attributes = serializedEventData[@"attributes"]; + + if (![tags isKindOfClass:[NSArray class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.tags should be an array"); + return; + } + if (![attributes isKindOfClass:[NSDictionary class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.attributes should be a dictionnary"); + return; + } + + for (NSString *tag in tags) + { + if (![tag isKindOfClass:[NSString class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.tag childrens should all be strings"); + return; + } + [batchEventData addTag:tag]; + } + + for (NSString *key in attributes.allKeys) + { + NSDictionary *typedAttribute = attributes[key]; + if (![typedAttribute isKindOfClass:[NSDictionary class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.attributes childrens should all be String/Dictionary tuples"); + return; + } + + NSString *type = typedAttribute[@"type"]; + NSObject *value = typedAttribute[@"value"]; + + if ([@"string" isEqualToString:type]) { + if (![value isKindOfClass:[NSString class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.attributes: expected string value, got something else"); + return; + } + [batchEventData putString:(NSString*)value forKey:key]; + } else if ([@"boolean" isEqualToString:type]) { + if (![value isKindOfClass:[NSNumber class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.attributes: expected number (boolean) value, got something else"); + return; + } + [batchEventData putBool:[(NSNumber*)value boolValue] forKey:key]; + } else if ([@"integer" isEqualToString:type]) { + if (![value isKindOfClass:[NSNumber class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.attributes: expected number (integer) value, got something else"); + return; + } + [batchEventData putInteger:[(NSNumber*)value integerValue] forKey:key]; + } else if ([@"float" isEqualToString:type]) { + if (![value isKindOfClass:[NSNumber class]]) + { + NSLog(@"RNBatch: Error while tracking event data: event data.attributes: expected number (float) value, got something else"); + return; + } + [batchEventData putDouble:[(NSNumber*)value doubleValue] forKey:key]; + } else { + NSLog(@"RNBatch: Error while tracking event data: Unknown event data.attributes type"); + return; + } + } + } + + [BatchUser trackEvent:name withLabel:label associatedData:batchEventData]; +} + +RCT_EXPORT_METHOD(userData_trackTransaction:(double)amount data:(NSDictionary*)rawData) +{ + if (rawData && ![rawData isKindOfClass:[NSDictionary class]]) + { + NSLog(@"RNBatch: trackTransaction data should be an dictionary or nil"); + return; + } + + [BatchUser trackTransactionWithAmount:amount data:rawData]; +} + +RCT_EXPORT_METHOD(userData_trackLocation:(NSDictionary*)serializedLocation) +{ + if (![serializedLocation isKindOfClass:[NSDictionary class]] || [serializedLocation count]==0) + { + NSLog(@"RNBatch: Empty or null parameters for trackLocation"); + return; + } + + NSNumber *latitude = serializedLocation[@"latitude"]; + NSNumber *longitude = serializedLocation[@"longitude"]; + NSNumber *date = serializedLocation[@"date"]; // MS + NSNumber *precision = serializedLocation[@"precision"]; + + if (![latitude isKindOfClass:[NSNumber class]]) + { + NSLog(@"RNBatch: latitude should be a string"); + return; + } + + if (![longitude isKindOfClass:[NSNumber class]]) + { + NSLog(@"RNBatch: longitude should be a string"); + return; + } + + NSTimeInterval ts = 0; + + if (date) + { + if ([date isKindOfClass:[NSNumber class]]) { + ts = [date doubleValue] / 1000.0; + } else { + NSLog(@"RNBatch: date should be an object or undefined"); + return; + } + } + + NSDate *parsedDate = ts != 0 ? [NSDate dateWithTimeIntervalSince1970:ts] : [NSDate date]; + + NSInteger parsedPrecision = 0; + if (precision) + { + if ([precision isKindOfClass:[NSNumber class]]) { + parsedPrecision = [precision integerValue]; + } else { + NSLog(@"RNBatch: precision should be an object or undefined"); + return; + } + } + + [BatchUser trackLocation:[[CLLocation alloc] initWithCoordinate:CLLocationCoordinate2DMake([latitude doubleValue], [longitude doubleValue]) + altitude:0 + horizontalAccuracy:parsedPrecision + verticalAccuracy:-1 + course:0 + speed:0 + timestamp:parsedDate]]; +} + // Inbox module RCT_EXPORT_METHOD(inbox_fetchNotifications:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/package.json b/package.json index c4eed92..68f8fca 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "doc:deploy": "yarn doc:generate && yarn doc:publish" }, "peerDependencies": { - "react-native": "*" + "react-native": "*", + "@types/react-native": "*" }, "dependencies": { "jest": "^24.1.0" diff --git a/readme/development.md b/readme/development.md index d3137b3..8812596 100644 --- a/readme/development.md +++ b/readme/development.md @@ -13,13 +13,14 @@ - Create a `mkdir @bam.tech` - Go into the folder `cd @bam.tech` - Add `local-modules` to your _.gitignore_ -- Clone the batch-push repository `git clone git@github.com:bamlab/react-native-batch-push.git` +- Clone the react-native-batch-push repository `git clone git@github.com:bamlab/react-native-batch-push.git` +- Rename `react-native-batch-push` to `react-native-batch` - Checkout the required branch or a new branch - Run `yarn` to install dependencies ## 3. Run build for development -- Open `@bam.tech/react-native-batch` within VSCode +- Open `local-modules/@bam.tech/react-native-batch` within VSCode - Run the `Task run build task >> tsc: watch` ## 4. Install the plugin on Android @@ -30,7 +31,7 @@ // android/settings.gradle include ':@bam.tech_react-native-batch' -project(':@bam.tech_react-native-batch').projectDir = new File(rootProject.projectDir, '../local-modules/@bam.tech/react-native-batch-push/android') +project(':@bam.tech_react-native-batch').projectDir = new File(rootProject.projectDir, '../local-modules/@bam.tech/react-native-batch/android') ``` ```groovy @@ -82,6 +83,8 @@ dependencies { implementation "com.google.firebase:firebase-messaging:17.3.4" ... } + +apply plugin: 'com.google.gms.google-services' ``` ### c. Add your Batch key @@ -104,7 +107,7 @@ defaultConfig { - Open the `/android` folder with _Android studio_ - Connect your phone to your computer - Run `adb reverse tcp:8081 tcp:8081` -- Tun `yarn start` +- Run `yarn start` - Run the project in debug mode ## 5. Install the plugin on iOS @@ -121,7 +124,7 @@ defaultConfig { - Open `/ios/.xcworkspace` - Select __ in XCode - Right click on _Librairies_ > _Add files to _ -- Select `/local-modules/@bam.tech/react-native-batch-push/ios/RNBatchPush.xcodeproj` +- Select `/local-modules/@bam.tech/react-native-batch/ios/RNBatchPush.xcodeproj` - In the project window select - _Build Phases_ - _Link Binary With Librairies_ diff --git a/src/Batch.ts b/src/Batch.ts index b0853be..d194440 100644 --- a/src/Batch.ts +++ b/src/Batch.ts @@ -1,4 +1,5 @@ import { NativeModules } from 'react-native'; +export * from './BatchEventData'; export * from './BatchInbox'; export * from './BatchMessaging'; export * from './BatchPush'; diff --git a/src/BatchEventData.ts b/src/BatchEventData.ts new file mode 100644 index 0000000..e2a3913 --- /dev/null +++ b/src/BatchEventData.ts @@ -0,0 +1,151 @@ +import Log from './helpers/Logger'; +import { isString, isNumber, isBoolean } from './helpers/TypeHelpers'; + +const Consts = { + AttributeKeyRegexp: /^[a-zA-Z0-9_]{1,30}$/, + EventDataMaxTags: 10, + EventDataMaxValues: 10, + EventDataStringMaxLength: 64, +}; + +export enum TypedEventAttributeType { + String = 'string', + Boolean = 'boolean', + Integer = 'integer', + Float = 'float', +} + +export interface ITypedEventAttribute { + type: TypedEventAttributeType; + value: string | boolean | number; +} + +export class BatchEventData { + private _tags: { [key: string]: true }; // tslint:disable-line + private _attributes: { [key: string]: ITypedEventAttribute }; // tslint:disable-line + + constructor() { + this._tags = {}; + this._attributes = {}; + } + + public addTag(tag: string): BatchEventData { + if (typeof tag === 'undefined') { + Log(false, 'BatchEventData - A tag is required'); + return this; + } + + if (isString(tag)) { + if (tag.length === 0 || tag.length > Consts.EventDataStringMaxLength) { + Log( + false, + "BatchEventData - Tags can't be empty or longer than " + + Consts.EventDataStringMaxLength + + " characters. Ignoring tag '" + + tag + + "'." + ); + return this; + } + } else { + Log(false, 'BatchEventData - Tag argument must be a string'); + return this; + } + + if (Object.keys(this._tags).length >= Consts.EventDataMaxTags) { + Log( + false, + 'BatchEventData - Event data cannot hold more than ' + + Consts.EventDataMaxTags + + " tags. Ignoring tag: '" + + tag + + "'" + ); + return this; + } + + this._tags[tag.toLowerCase()] = true; + + return this; + } + + public put(key: string, value: string | number | boolean): BatchEventData { + if (!isString(key)) { + Log(false, 'BatchEventData - Key must be a string'); + return this; + } + + if (!Consts.AttributeKeyRegexp.test(key || '')) { + Log( + false, + "BatchEventData - Invalid key. Please make sure that the key is made of letters, underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring attribute '" + + key + + "'" + ); + return this; + } + + if (typeof value === 'undefined' || value === null) { + Log(false, 'BatchEventData - Value cannot be undefined or null'); + return this; + } + + key = key.toLowerCase(); + + if ( + Object.keys(this._tags).length >= Consts.EventDataMaxValues && + !this._attributes.hasOwnProperty(key) + ) { + Log( + false, + 'BatchEventData - Event data cannot hold more than ' + + Consts.EventDataMaxValues + + " attributes. Ignoring attribute: '" + + key + + "'" + ); + return this; + } + + let typedAttrValue: ITypedEventAttribute | undefined; + + if (isString(value)) { + typedAttrValue = { + type: TypedEventAttributeType.String, + value, + }; + } else if (isNumber(value)) { + typedAttrValue = { + type: + value % 1 === 0 + ? TypedEventAttributeType.Integer + : TypedEventAttributeType.Float, + value, + }; + } else if (isBoolean(value)) { + typedAttrValue = { + type: TypedEventAttributeType.Boolean, + value, + }; + } else { + Log( + false, + 'BatchEventData - Invalid attribute value type. Must be a string, number or boolean' + ); + return this; + } + + if (typedAttrValue) { + this._attributes[key] = typedAttrValue; + } + + return this; + } + + protected _toInternalRepresentation() { + return { + attributes: this._attributes, + tags: Object.keys(this._tags), + }; + } +} diff --git a/src/BatchUser.ts b/src/BatchUser.ts index ecf3bb7..e94e165 100644 --- a/src/BatchUser.ts +++ b/src/BatchUser.ts @@ -1,7 +1,36 @@ import { NativeModules } from 'react-native'; import { BatchUserEditor } from './BatchUserEditor'; +import { BatchEventData } from './BatchEventData'; +import { isString, isNumber } from './helpers/TypeHelpers'; +import Log from './helpers/Logger'; + const RNBatch = NativeModules.RNBatch; +/** + * Represents a locations, using lat/lng coordinates + */ +export interface Location { + /** + * Latitude + */ + latitude: number; + + /** + * Longitude + */ + longitude: number; + + /** + * Date of the tracked location + */ + date?: Date; + + /** + * Precision radius in meters + */ + precision?: number; +} + /** * Batch's user module */ @@ -18,4 +47,94 @@ export const BatchUser = { * The profile is not updated until the method `save()` is called */ editor: (): BatchUserEditor => new BatchUserEditor(), + + /** + * Track an event. Batch must be started at some point, or events won't be sent to the server. + * @param name The event name. Must be a string. + * @param label The event label (optional). Must be a string. + * @param data The event data (optional). Must be an object. + */ + trackEvent: (name: string, label?: string, data?: BatchEventData): void => { + //TODO (arnaud): Check if "isString" really is necessary. Same for data + // Since _toInternalRepresentation is private, we have to resort to this little hack to access the method. + // That syntax keeps the argument type checking, while casting as any would not. + RNBatch.userData_trackEvent( + name, + isString(label) ? label : null, + data instanceof BatchEventData + ? data['_toInternalRepresentation']() + : null + ); + }, + + /** + * Track a transaction. Batch must be started at some point, or events won't be sent to the server. + * @param amount Transaction's amount. + * @param data The transaction data (optional). Must be an object. + */ + trackTransaction: (amount: number, data?: { [key: string]: any }): void => { + if (typeof amount === 'undefined') { + Log( + false, + 'BatchUser - Amount must be a valid number. Ignoring transaction.' + ); + return; + } + + if (!isNumber(amount) || isNaN(amount)) { + Log( + false, + 'BatchUser - Amount must be a valid number. Ignoring transaction.' + ); + return; + } + + if (typeof data !== 'object') { + data = null; + } + + RNBatch.userData_trackTransaction(amount, data); + }, + + /** + * Track a geolocation update + * You can call this method from any thread. Batch must be started at some point, or location updates won't be sent to the server. + * @param location User location object + */ + trackLocation: (location: Location): void => { + if (typeof location !== 'object') { + Log(false, 'BatchUser - Invalid trackLocation argument. Skipping.'); + return; + } + + if (typeof location.latitude !== 'number' || isNaN(location.latitude)) { + Log(false, 'BatchUser - Invalid latitude. Skipping.'); + return; + } + + if (typeof location.longitude !== 'number' || isNaN(location.longitude)) { + Log(false, 'BatchUser - Invalid longitude. Skipping.'); + return; + } + + if ( + location.precision && + (typeof location.precision !== 'number' || isNaN(location.precision)) + ) { + Log(false, 'BatchUser - Invalid precision. Skipping.'); + return; + } + + if (location.date && !(location.date instanceof Date)) { + Log(false, 'BatchUser - Invalid date. Skipping.'); + return; + } + + RNBatch.userData_trackLocation({ + date: location.date ? location.date.getTime() : undefined, + latitude: location.latitude, + longitude: location.longitude, + precision: location.precision, + }); + }, }; diff --git a/src/helpers/Logger.ts b/src/helpers/Logger.ts new file mode 100644 index 0000000..56acf82 --- /dev/null +++ b/src/helpers/Logger.ts @@ -0,0 +1,10 @@ +const DEBUG = false; + +export default function Log(debug: boolean, ...message: any[]) { + const args = ['[Batch]'].concat(Array.prototype.slice.call(arguments, 1)); + if (DEBUG === true && debug === true) { + console.debug.apply(console, args); + } else if (debug === false) { + console.log.apply(console, args); + } +} diff --git a/src/helpers/TypeHelpers.ts b/src/helpers/TypeHelpers.ts new file mode 100644 index 0000000..13575f4 --- /dev/null +++ b/src/helpers/TypeHelpers.ts @@ -0,0 +1,13 @@ +export function isString(value: any): value is string { + return value instanceof String || typeof value === 'string'; +} + +export function isNumber(value: any): value is number { + return ( + value instanceof Number || (typeof value === 'number' && !isNaN(value)) + ); +} + +export function isBoolean(value: any): value is boolean { + return value instanceof Boolean || typeof value === 'boolean'; +} diff --git a/tsconfig.json b/tsconfig.json index bab780c..fcb2021 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,9 @@ "declaration": true, "sourceMap": true, "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "lib": ["es6"], + "types": ["jest"] }, "exclude": ["node_modules", "**/node_modules/*", "dist", "**/*.test.*"] }