Last active
February 8, 2021 19:22
-
-
Save thomasmso/799427719a75e2c860e629fd2701a44a to your computer and use it in GitHub Desktop.
MAX AdColony Adapter - iOS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// ALAdColonyMediationAdapter.m | |
// AppLovinSDK | |
// | |
// Created by Thomas So on 2/16/19. | |
// Copyright © 2019 AppLovin Corporation. All rights reserved. | |
// | |
#import "ALAdColonyMediationAdapter.h" | |
#import <AdColony/AdColony.h> | |
#import "ALUtils.h" | |
#import "NSDictionary+ALUtils.h" | |
#import "NSString+ALUtils.h" | |
#import "ALAtomicBoolean.h" | |
#import "MAAdFormat+Internal.h" | |
#define ADAPTER_VERSION @"4.5.0.0.1" | |
@interface ALAdColonyInterstitialDelegate : NSObject<AdColonyInterstitialDelegate> | |
@property (nonatomic, weak) ALAdColonyMediationAdapter *parentAdapter; | |
@property (nonatomic, strong) id<MAInterstitialAdapterDelegate> delegate; | |
- (instancetype)initWithParentAdapter:(ALAdColonyMediationAdapter *)parentAdapter andNotify:(id<MAInterstitialAdapterDelegate>)delegate; | |
@end | |
@interface ALAdColonyRewardedAdDelegate : NSObject<AdColonyInterstitialDelegate> | |
@property (nonatomic, weak) ALAdColonyMediationAdapter *parentAdapter; | |
@property (nonatomic, strong) id<MARewardedAdapterDelegate> delegate; | |
@property (nonatomic, assign, getter=hasGrantedReward) BOOL grantedReward; | |
- (instancetype)initWithParentAdapter:(ALAdColonyMediationAdapter *)parentAdapter andNotify:(id<MARewardedAdapterDelegate>)delegate; | |
@end | |
@interface ALAdColonyMediationAdapterAdViewDelegate : NSObject<AdColonyAdViewDelegate> | |
@property (nonatomic, weak) ALAdColonyMediationAdapter *parentAdapter; | |
@property (nonatomic, strong) id<MAAdViewAdapterDelegate> delegate; | |
- (instancetype)initWithParentAdapter:(ALAdColonyMediationAdapter *)parentAdapter andNotify:(id<MAAdViewAdapterDelegate>)delegate; | |
@end | |
@interface ALAdColonyMediationAdapter() | |
// Initialize the SDK only once | |
@property (atomic, assign) BOOL initialized; | |
// Interstitial | |
@property (nonatomic, strong) AdColonyInterstitial *loadedInterstitialAd; | |
@property (nonatomic, strong) ALAdColonyInterstitialDelegate *interstitialAdDelegate; | |
// Rewarded | |
@property (nonatomic, strong) AdColonyInterstitial *loadedRewardedAd; | |
@property (nonatomic, strong) ALAdColonyRewardedAdDelegate *rewardedAdDelegate; | |
// Banner/MREC | |
@property (nonatomic, strong) AdColonyAdView *loadedAdViewAd; | |
@property (nonatomic, strong) ALAdColonyMediationAdapterAdViewDelegate *adViewDelegate; | |
@end | |
@implementation ALAdColonyMediationAdapter | |
static ALAtomicBoolean *ALAdColonyInitialized; | |
static MAAdapterInitializationStatus ALAdColonyInitializationStatus = NSIntegerMin; | |
+ (void)initialize | |
{ | |
[super initialize]; | |
ALAdColonyInitialized = [[ALAtomicBoolean alloc] init]; | |
} | |
#pragma mark - MAAdapter Methods | |
- (NSString *)SDKVersion | |
{ | |
return [AdColony getSDKVersion]; | |
} | |
- (NSString *)adapterVersion | |
{ | |
return ADAPTER_VERSION; | |
} | |
- (void)initializeWithParameters:(id<MAAdapterInitializationParameters>)parameters completionHandler:(void (^)(MAAdapterInitializationStatus, NSString * _Nullable))completionHandler | |
{ | |
if ( [ALAdColonyInitialized compareAndSet: NO update: YES] ) | |
{ | |
ALAdColonyInitializationStatus = MAAdapterInitializationStatusInitializing; | |
NSString *appID = [parameters.serverParameters al_stringForKey: @"app_id"]; | |
[self log: @"Initializing AdColony SDK with app id: %@...", appID]; | |
NSArray<NSString *> *zoneIDs = [parameters.serverParameters al_arrayForKey: @"zone_ids"]; | |
AdColonyAppOptions *options = [self optionsFromParameters: parameters]; | |
[AdColony configureWithAppID: appID zoneIDs: zoneIDs options: options completion:^(NSArray<AdColonyZone *> *zones) { | |
[self log: @"AdColony SDK initialized with zones: %@", [self retrieveRawZoneIDs: zones]]; | |
if ( zones ) | |
{ | |
ALAdColonyInitializationStatus = MAAdapterInitializationStatusInitializedSuccess; | |
completionHandler(ALAdColonyInitializationStatus, nil); | |
} | |
else | |
{ | |
ALAdColonyInitializationStatus = MAAdapterInitializationStatusInitializedFailure; | |
completionHandler(ALAdColonyInitializationStatus, @"no_zones"); | |
} | |
}]; | |
} | |
else | |
{ | |
completionHandler(ALAdColonyInitializationStatus, nil); | |
} | |
} | |
- (void)destroy | |
{ | |
if ( self.loadedRewardedAd ) | |
{ | |
AdColonyZone *zone = [AdColony zoneForID: self.loadedRewardedAd.zoneID]; | |
[zone setReward: nil]; | |
} | |
self.loadedInterstitialAd = nil; | |
self.interstitialAdDelegate = nil; | |
self.loadedRewardedAd = nil; | |
self.rewardedAdDelegate = nil; | |
[self.loadedAdViewAd destroy]; | |
self.loadedAdViewAd = nil; | |
self.adViewDelegate = nil; | |
} | |
#pragma mark - MASignalProvider Methods | |
- (void)collectSignalWithParameters:(id<MASignalCollectionParameters>)parameters andNotify:(id<MASignalCollectionDelegate>)delegate | |
{ | |
NSString *token = [AdColony collectSignals]; | |
[delegate didCollectSignal: token]; | |
} | |
#pragma mark - Interstitial Adapter | |
- (void)loadInterstitialAdForParameters:(id<MAAdapterResponseParameters>)parameters andNotify:(id<MAInterstitialAdapterDelegate>)delegate | |
{ | |
NSString *zoneId = parameters.thirdPartyAdPlacementIdentifier; | |
NSString *bidResponse = parameters.bidResponse; | |
[self log: @"Loading %@interstitial ad for zone id \"%@\"...", [bidResponse al_isValidString] ? @"bidding " : @"", zoneId]; | |
[AdColony setAppOptions: [self optionsFromParameters: parameters]]; | |
self.interstitialAdDelegate = [[ALAdColonyInterstitialDelegate alloc] initWithParentAdapter: self andNotify: delegate]; | |
[AdColony requestInterstitialInZone: zoneId options: nil andDelegate: self.interstitialAdDelegate]; | |
} | |
- (void)showInterstitialAdForParameters:(id<MAAdapterResponseParameters>)parameters andNotify:(id<MAInterstitialAdapterDelegate>)delegate | |
{ | |
[self log: @"Showing interstitial ad..."]; | |
if ( !self.loadedInterstitialAd ) | |
{ | |
[self log: @"Interstitial ad not ready"]; | |
[delegate didFailToDisplayInterstitialAdWithError: MAAdapterError.adNotReady]; | |
return; | |
} | |
if ( [self.loadedInterstitialAd expired] ) | |
{ | |
[self log: @"Interstitial ad is expired"]; | |
[delegate didFailToDisplayInterstitialAdWithError: MAAdapterError.adExpiredError]; | |
return; | |
} | |
BOOL success = [self.loadedInterstitialAd showWithPresentingViewController: [ALUtils topViewControllerFromKeyWindow]]; | |
if ( !success ) | |
{ | |
[self log: @"Interstitial ad failed to display"]; | |
[delegate didFailToDisplayInterstitialAdWithError: MAAdapterError.unspecified]; | |
} | |
} | |
#pragma mark - Rewarded Adapter | |
- (void)loadRewardedAdForParameters:(id<MAAdapterResponseParameters>)parameters andNotify:(id<MARewardedAdapterDelegate>)delegate | |
{ | |
NSString *zoneId = parameters.thirdPartyAdPlacementIdentifier; | |
NSString *bidResponse = parameters.bidResponse; | |
[self log: @"Loading %@rewarded ad for zone id \"%@\"...", [bidResponse al_isValidString] ? @"bidding " : @"", zoneId]; | |
[AdColony setAppOptions: [self optionsFromParameters: parameters]]; | |
self.rewardedAdDelegate = [[ALAdColonyRewardedAdDelegate alloc] initWithParentAdapter: self andNotify: delegate]; | |
[AdColony requestInterstitialInZone: zoneId options: nil andDelegate: self.rewardedAdDelegate]; | |
} | |
- (void)showRewardedAdForParameters:(id<MAAdapterResponseParameters>)parameters andNotify:(id<MARewardedAdapterDelegate>)delegate | |
{ | |
[self log: @"Showing rewarded ad..."]; | |
if ( !self.loadedRewardedAd ) | |
{ | |
[self log: @"Rewarded ad not ready"]; | |
[delegate didFailToDisplayRewardedAdWithError: MAAdapterError.adNotReady]; | |
return; | |
} | |
if ( [self.loadedRewardedAd expired] ) | |
{ | |
[self log: @"Rewarded ad is expired"]; | |
[delegate didFailToDisplayRewardedAdWithError: MAAdapterError.adExpiredError]; | |
return; | |
} | |
[self configureRewardForParameters: parameters]; | |
BOOL success = [self.loadedRewardedAd showWithPresentingViewController: [ALUtils topViewControllerFromKeyWindow]]; | |
if ( !success ) | |
{ | |
[self log: @"Rewarded ad failed to display"]; | |
[delegate didFailToDisplayRewardedAdWithError: MAAdapterError.unspecified]; | |
} | |
} | |
#pragma mark - AdView Adapter | |
- (void)loadAdViewAdForParameters:(id<MAAdapterResponseParameters>)parameters adFormat:(MAAdFormat *)adFormat andNotify:(id<MAAdViewAdapterDelegate>)delegate | |
{ | |
NSString *zoneId = parameters.thirdPartyAdPlacementIdentifier; | |
NSString *bidResponse = parameters.bidResponse; | |
[self log: @"Loading %@%@ ad for zone id \"%@\"...", [bidResponse al_isValidString] ? @"bidding " : @"", adFormat.label, zoneId]; | |
[AdColony setAppOptions: [self optionsFromParameters: parameters]]; | |
AdColonyAdSize adSize = [self sizeFromAdFormat: adFormat]; | |
self.adViewDelegate = [[ALAdColonyMediationAdapterAdViewDelegate alloc] initWithParentAdapter:self andNotify: delegate]; | |
[AdColony requestAdViewInZone: zoneId | |
withSize: adSize | |
viewController: [ALUtils topViewControllerFromKeyWindow] | |
andDelegate: self.adViewDelegate]; | |
} | |
#pragma mark - Helper Methods | |
- (AdColonyAdSize)sizeFromAdFormat:(MAAdFormat *)adFormat | |
{ | |
if ( adFormat == MAAdFormat.banner ) | |
{ | |
return kAdColonyAdSizeBanner; | |
} | |
else if ( adFormat == MAAdFormat.leader ) | |
{ | |
return kAdColonyAdSizeLeaderboard; | |
} | |
else if ( adFormat == MAAdFormat.mrec ) | |
{ | |
return kAdColonyAdSizeMediumRectangle; | |
} | |
else | |
{ | |
[NSException raise: NSInvalidArgumentException format: @"Unsupported ad format: %@", adFormat ]; | |
return AdColonyAdSizeMake(0, 0); | |
} | |
} | |
- (NSArray<NSString *> *)retrieveRawZoneIDs:(NSArray<AdColonyZone *> *)zones | |
{ | |
NSMutableArray<NSString *> *rawZoneIDs = [NSMutableArray arrayWithCapacity: zones.count]; | |
for ( AdColonyZone *zone in zones ) | |
{ | |
[rawZoneIDs addObject: zone.identifier]; | |
} | |
return rawZoneIDs; | |
} | |
- (MAAdapterError *)adapterErrorFromAdColonyError:(NSError *)adColonyError | |
{ | |
NSInteger adColonyErrorCode = adColonyError.code; | |
MAAdapterError *adapterError; | |
if ( adColonyErrorCode == 0 ) | |
{ | |
adapterError = MAAdapterError.notInitialized; // SDK is not initialized | |
} | |
else if ( adColonyErrorCode == 2 ) | |
{ | |
adapterError = MAAdapterError.noFill; | |
} | |
else | |
{ | |
adapterError = MAAdapterError.unspecified; | |
} | |
return [MAAdapterError errorWithCode: adapterError.errorCode | |
errorString: adapterError.errorMessage | |
thirdPartySdkErrorCode: adColonyErrorCode | |
thirdPartySdkErrorMessage: adColonyError.localizedDescription]; | |
} | |
- (AdColonyAppOptions *)optionsFromParameters:(id<MAAdapterParameters >)parameters | |
{ | |
NSDictionary<NSString *, id> *serverParameters = parameters.serverParameters; | |
AdColonyAppOptions *options = [[AdColonyAppOptions alloc] init]; | |
// | |
// Basic options | |
// | |
options.testMode = [parameters isTesting]; | |
options.mediationNetwork = @"AppLovin"; | |
options.mediationNetworkVersion = ALSdk.version; | |
// | |
// GDPR options | |
// | |
if ( self.sdk.configuration.consentDialogState == ALConsentDialogStateApplies ) | |
{ | |
[options setPrivacyFrameworkOfType: ADC_GDPR isRequired: YES]; | |
NSNumber *hasUserConsent = [self privacySettingForSelector: @selector(hasUserConsent) fromParameters: parameters]; | |
if ( hasUserConsent ) | |
{ | |
[options setPrivacyConsentString: hasUserConsent.boolValue ? @"1" : @"0" forType: ADC_GDPR]; | |
} | |
} | |
else if ( self.sdk.configuration.consentDialogState == ALConsentDialogStateDoesNotApply ) | |
{ | |
[options setPrivacyFrameworkOfType: ADC_GDPR isRequired: NO]; | |
} | |
// | |
// CCPA options | |
// | |
if ( ALSdk.versionCode >= 61100 ) | |
{ | |
NSNumber *isDoNotSell = [self privacySettingForSelector: @selector(isDoNotSell) fromParameters: parameters]; | |
if ( isDoNotSell ) | |
{ | |
[options setPrivacyFrameworkOfType: ADC_CCPA isRequired: YES]; | |
[options setPrivacyConsentString: isDoNotSell.boolValue ? @"0" : @"1" forType: ADC_CCPA]; // isDoNotSell means user has opted out of selling data. | |
} | |
else | |
{ | |
[options setPrivacyFrameworkOfType: ADC_CCPA isRequired: NO]; | |
} | |
} | |
// | |
// COPPA options | |
// | |
NSNumber *isAgeRestrictedUser = [self privacySettingForSelector: @selector(isAgeRestrictedUser) fromParameters: parameters]; | |
if ( isAgeRestrictedUser ) | |
{ | |
[options setPrivacyFrameworkOfType: ADC_COPPA isRequired: isAgeRestrictedUser.boolValue]; | |
[options setPrivacyConsentString: @"" forType: ADC_COPPA]; // Setting empty string to clear any previously set values. | |
} | |
// | |
// Bidding options | |
// | |
// If AdColony wins the auction, network adapters need to send any .adm content via ad_options to the AdColony SDK when making the ad request | |
if ( [parameters conformsToProtocol: @protocol(MAAdapterResponseParameters)] ) | |
{ | |
NSString *bidResponse = ((id<MAAdapterResponseParameters>)parameters).bidResponse; | |
if ( [bidResponse al_isValidString] ) | |
{ | |
[options setOption: @"adm" withStringValue: bidResponse]; | |
} | |
} | |
// | |
// Other options | |
// | |
if ( [serverParameters al_containsValueForKey: @"plugin"] && [serverParameters al_containsValueForKey: @"plugin_version"] ) | |
{ | |
options.plugin = [serverParameters al_stringForKey: @"plugin"]; | |
options.pluginVersion = [serverParameters al_stringForKey: @"plugin_version"]; | |
} | |
if ( [serverParameters al_containsValueForKey: @"user_id"] ) | |
{ | |
options.userID = [serverParameters al_stringForKey: @"user_id"]; | |
} | |
return options; | |
} | |
- (nullable NSNumber *)privacySettingForSelector:(SEL)selector fromParameters:(id<MAAdapterParameters>)parameters | |
{ | |
// Use reflection because compiled adapters have trouble fetching `BOOL` from old SDKs and `NSNumber` from new SDKs (above 6.14.0) | |
NSMethodSignature *signature = [[parameters class] instanceMethodSignatureForSelector: selector]; | |
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature: signature]; | |
[invocation setSelector: selector]; | |
[invocation setTarget: parameters]; | |
[invocation invoke]; | |
// Privacy parameters return nullable `NSNumber` on newer SDKs | |
if ( ALSdk.versionCode >= 6140000 ) | |
{ | |
NSNumber *__unsafe_unretained value; | |
[invocation getReturnValue: &value]; | |
return value; | |
} | |
// Privacy parameters return BOOL on older SDKs | |
else | |
{ | |
BOOL rawValue; | |
[invocation getReturnValue: &rawValue]; | |
return @(rawValue); | |
} | |
} | |
@end | |
#pragma mark - ALAdColonyInterstitialDelegate | |
@implementation ALAdColonyInterstitialDelegate | |
- (instancetype)initWithParentAdapter:(ALAdColonyMediationAdapter *)parentAdapter andNotify:(id<MAInterstitialAdapterDelegate>)delegate | |
{ | |
self = [super init]; | |
if ( self ) | |
{ | |
self.parentAdapter = parentAdapter; | |
self.delegate = delegate; | |
} | |
return self; | |
} | |
- (void)adColonyInterstitialDidLoad:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Interstitial loaded"]; | |
self.parentAdapter.loadedInterstitialAd = interstitial; | |
[self.delegate didLoadInterstitialAd]; | |
} | |
- (void)adColonyInterstitialDidFailToLoad:(AdColonyAdRequestError *)error | |
{ | |
[self.parentAdapter log: @"Interstitial failed to load with error: %@", error]; | |
MAAdapterError *adapterError = [self.parentAdapter adapterErrorFromAdColonyError: error]; | |
[self.delegate didFailToLoadInterstitialAdWithError: adapterError]; | |
} | |
- (void)adColonyInterstitialWillOpen:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Interstitial shown"]; | |
[self.delegate didDisplayInterstitialAd]; | |
} | |
- (void)adColonyInterstitialDidClose:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Interstitial hidden"]; | |
[self.delegate didHideInterstitialAd]; | |
} | |
- (void)adColonyInterstitialExpired:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Interstitial expiring: %@", interstitial.zoneID]; | |
} | |
- (void)adColonyInterstitialWillLeaveApplication:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Interstitial will leave application"]; | |
} | |
- (void)adColonyInterstitialDidReceiveClick:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Interstitial clicked"]; | |
[self.delegate didClickInterstitialAd]; | |
} | |
@end | |
#pragma mark - ALAdColonyRewardedAdDelegate | |
@implementation ALAdColonyRewardedAdDelegate | |
- (instancetype)initWithParentAdapter:(ALAdColonyMediationAdapter *)parentAdapter andNotify:(id<MARewardedAdapterDelegate>)delegate | |
{ | |
self = [super init]; | |
if ( self ) | |
{ | |
self.parentAdapter = parentAdapter; | |
self.delegate = delegate; | |
} | |
return self; | |
} | |
- (void)adColonyInterstitialDidLoad:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Rewarded ad loaded"]; | |
self.parentAdapter.loadedRewardedAd = interstitial; | |
AdColonyZone *zone = [AdColony zoneForID: interstitial.zoneID]; | |
[zone setReward:^(BOOL success, NSString *name, int amount) { | |
if ( success ) | |
{ | |
[self.parentAdapter log: @"Rewarded ad granted reward"]; | |
self.grantedReward = YES; | |
} | |
else | |
{ | |
[self.parentAdapter log: @"Rewarded ad failed to grant reward"]; | |
} | |
}]; | |
[self.delegate didLoadRewardedAd]; | |
} | |
- (void)adColonyInterstitialDidFailToLoad:(AdColonyAdRequestError *)error | |
{ | |
[self.parentAdapter log: @"Rewarded failed to load with error: %@", error]; | |
MAAdapterError *adapterError = [self.parentAdapter adapterErrorFromAdColonyError: error]; | |
[self.delegate didFailToLoadRewardedAdWithError: adapterError]; | |
} | |
- (void)adColonyInterstitialWillOpen:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Rewarded ad shown"]; | |
[self.delegate didDisplayRewardedAd]; | |
[self.delegate didStartRewardedAdVideo]; | |
} | |
- (void)adColonyInterstitialDidClose:(AdColonyInterstitial *)interstitial | |
{ | |
[self.delegate didCompleteRewardedAdVideo]; | |
if ( [self hasGrantedReward] || [self.parentAdapter shouldAlwaysRewardUser] ) | |
{ | |
MAReward *reward = [self.parentAdapter reward]; | |
[self.parentAdapter log: @"Rewarded user with reward: %@", reward]; | |
[self.delegate didRewardUserWithReward: reward]; | |
} | |
[self.parentAdapter log: @"Rewarded ad hidden"]; | |
[self.delegate didHideRewardedAd]; | |
} | |
- (void)adColonyInterstitialExpired:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Rewarded ad expiring: %@", interstitial.zoneID]; | |
} | |
- (void)adColonyInterstitialWillLeaveApplication:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Rewarded ad will leave application"]; | |
} | |
- (void)adColonyInterstitialDidReceiveClick:(AdColonyInterstitial *)interstitial | |
{ | |
[self.parentAdapter log: @"Rewarded ad clicked"]; | |
[self.delegate didClickRewardedAd]; | |
} | |
@end | |
@implementation ALAdColonyMediationAdapterAdViewDelegate : NSObject | |
- (instancetype)initWithParentAdapter:(ALAdColonyMediationAdapter *)parentAdapter andNotify:(id<MAAdViewAdapterDelegate>)delegate | |
{ | |
self = [super init]; | |
if ( self ) | |
{ | |
self.parentAdapter = parentAdapter; | |
self.delegate = delegate; | |
} | |
return self; | |
} | |
- (void)adColonyAdViewDidLoad:(AdColonyAdView *)adView | |
{ | |
[self.parentAdapter log: @"Ad View loaded"]; | |
self.parentAdapter.loadedAdViewAd = adView; | |
[self.delegate didLoadAdForAdView: adView]; | |
} | |
- (void)adColonyAdViewDidFailToLoad:(AdColonyAdRequestError *)error | |
{ | |
[self.parentAdapter log: @"Ad View failed to load with error: %@", error]; | |
MAAdapterError *adapterError = [self.parentAdapter adapterErrorFromAdColonyError: error]; | |
[self.delegate didFailToLoadAdViewAdWithError: adapterError]; | |
} | |
- (void)adColonyAdViewWillLeaveApplication:(AdColonyAdView *)adView | |
{ | |
[self.parentAdapter log: @"Ad View ad will leave application"]; | |
} | |
- (void)adColonyAdViewDidReceiveClick:(AdColonyAdView *)adView | |
{ | |
[self.parentAdapter log: @"Ad View ad clicked"]; | |
[self.delegate didClickAdViewAd]; | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment