MaterialMonitor is an ad creative monitoring SDK. It captures ad screenshots (including video-frame composition) plus metadata during ad display, and uploads them automatically to the creative audit server for compliance review and quality monitoring.
| Capability | Description |
|---|---|
| Screenshot capture | Supports splash / Banner / interstitial / rewarded video / native ads |
| Video composition capture | Automatically detects AVPlayerLayer and composes video frames plus UIKit overlays |
| Persistent queue | Sandbox persistence + cold-start recovery + retries + network awareness |
| Automatic upload | Built-in signing (HMAC-MD5) + multipart upload + rate-limit retry strategy |
| Privacy protection | Automatically masks password fields / firstResponder; supports custom masked regions |
| Policy awareness | Supports dynamic enable/disable by placementId / networkFirmId / style |
Ensure that the minimum iOS version for the project is 13.0.
Add the dependency to the Podfile:
platform :ios, '13.0'
target 'YourTarget' do
pod 'TPNMaterialMonitorSDK', '~> 1.0.0'
end
Perform the installation in the directory where the Podfile is located:
pod install --repo-update
Then open the project using the generated .xcworkspace. The SDK module name is MaterialMonitorSDK, used in Swift as follows:
import MaterialMonitorSDK
Using automatically generated Swift header files in Objective-C:
#import <MaterialMonitorSDK/MaterialMonitorSDK-Swift.h>
TPNMaterialMonitorSDK.podspec downloads the framework archive through s.source. Download link:
http://info.appsmartsite.com/Material_monitor/nochina/1.0.0/MaterialMonitorSDK-1.0.0.zip
After decompression, the compressed file contains the following directory structure:
MaterialMonitorSDK-1.0.0/
MaterialMonitorSDK.xcframework/
When to call: recommended after the ATT authorization callback returns (so IDFA is available). Call it only once during the entire app lifecycle.
import MaterialMonitorSDK
import AppTrackingTransparency
import AdSupport
class AppDelegate: UIResponder, UIApplicationDelegate {
// Recommended: run the ATT authorization flow in applicationDidBecomeActive
func applicationDidBecomeActive(_ application: UIApplication) {
if #available(iOS 14, *) {
ATTrackingManager.requestTrackingAuthorization { _ in
self.initMaterialMonitor()
}
} else {
initMaterialMonitor()
}
}
private func initMaterialMonitor() {
let appId = "your_app_id"
let appKey = "your_app_key"
let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
let sdkVersion = "your_host_sdk_version" // host aggregator SDK version
// MARK: - MaterialMonitor SDK initialization (full parameters)
let config = MaterialMonitorConfig.Builder()
// -- upload service URL -----------------------------------------
.baseUploadUrl("https://your-upload-host.com")
// -- authentication ---------------------------------------------
.appId(appId)
.appKey(appKey)
// -- device identifiers ----------------------------------------
.idfa(idfa) // pass ATTrackingManager.advertisingIdentifier.uuidString after ATT
.idfv(nil) // when nil, SDK reads UIDevice.current.identifierForVendor automatically
.sdkVersion(sdkVersion) // host aggregator SDK version, written into meta device_info.sdk_version
.caid(nil) // CAID (China Advertising ID, optional)
// -- network timeout (ms) --------------------------------------
.connectTimeoutMs(15_000) // TCP connect timeout, default 15s, minimum 3s
.readTimeoutMs(30_000) // server response timeout, default 30s, minimum 3s
// -- retry policy ----------------------------------------------
.maxUploadAttempts(3) // first send + up to 2 retries, minimum 1
.retryDelayMs([5_000, 15_000]) // retry delays (Swift only)
// -- debugging (keep false in production) ----------------------
.debugPerfLogging(false)
.build()
MaterialMonitor.initialize(config: config)
}
}
#import <MaterialMonitorSDK/MaterialMonitorSDK-Swift.h>
#import <AppTrackingTransparency/AppTrackingTransparency.h>
#import <AdSupport/AdSupport.h>
- (void)applicationDidBecomeActive:(UIApplication *)application {
if (@available(iOS 14, *)) {
[ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) {
[self initMaterialMonitor];
}];
} else {
[self initMaterialMonitor];
}
}
- (void)initMaterialMonitor {
NSString *appId = @"your_app_id";
NSString *appKey = @"your_app_key";
NSString *idfa = [[[ASIdentifierManager sharedManager] advertisingIdentifier] UUIDString];
NSString *sdkVersion = @"6.5.0";
MMConfigBuilder *builder = [[MMConfigBuilder alloc] init];
// -- upload service URL --
[builder baseUploadUrl:@"https://your-upload-host.com"];
// -- authentication --
[builder appId:appId];
[builder appKey:appKey];
// -- device identifiers --
[builder idfa:idfa];
[builder idfv:nil];
[builder sdkVersion:sdkVersion];
[builder caid:nil];
// -- network timeout (ms) --
[builder connectTimeoutMs:15000];
[builder readTimeoutMs:30000];
// -- retry policy (ObjC does not support custom retryDelayMs, use default [5000, 15000]) --
[builder maxUploadAttempts:3];
// -- debugging (keep NO in production) --
[builder debugPerfLogging:NO];
MaterialMonitorConfig *config = [builder build];
[MaterialMonitor initializeWithConfig:config];
}
Tip 1:
MaterialMonitoris a singleton (MaterialMonitor.shared). After initialization, call all instance methods throughMaterialMonitor.shared.xxx().Tip 2: Every parameter has a reasonable default. Only
appIdandappKeyare strictly required for the SDK to work; override the others as needed.Tip 3: ObjC does not support
retryDelayMs(_:)(Swift array bridging limitation). ObjC projects use the built-in default[5000, 15000].
You may call initialize(config:) repeatedly at runtime. The SDK will automatically tear down the old scheduler (cancel timers, interrupt in-flight requests, clear callbacks), then rebuild with the new config. Cold-start scanning will run again, while existing tasks on disk are preserved.
MaterialMonitor.shutdown()
[MaterialMonitor shutdown];
Behavior after shutdown:
| Item | Behavior |
|---|---|
| Capture / upload methods | Return MMErrorCodeUninitialized immediately |
| In-flight upload requests | Hard-cancelled (URLSession invalidateAndCancel) |
| Retry timers, network listeners | Released immediately |
| Business callback registry | Cleared (already-issued callbacks are delivered normally; later callbacks are silently dropped) |
| Records persisted on disk | Kept and continue uploading on the next cold-start scan after initialize |
| Config | Cleared |
Idempotent: calling shutdown() multiple times is safe.
Restartable: calling initialize again after shutdown restores full functionality.
MaterialMonitorConfigBuild it with the Builder pattern. All config items and defaults are listed below:
| Field | Type | Default | Description |
|---|---|---|---|
baseUploadUrl |
String | "" |
Upload root domain (for example "https://api.example.com"). The SDK automatically appends /v1/creative/upload |
appId |
String | "" |
App ID, written to multipart field app_id |
appKey |
String | "" |
HMAC-MD5 signing key. Never print, cache, or upload in plaintext |
idfa |
String? | nil | IDFA after ATT authorization. The SDK passes it through directly and does not filter all-zero values |
idfv |
String? | nil | IDFV. If nil, the SDK reads UIDevice.current.identifierForVendor automatically |
sdkVersion |
String? | nil | Host aggregator SDK version, written into meta device_info.sdk_version |
caid |
String? | nil | CAID (China Advertising ID) |
connectTimeoutMs |
Int | 15000 | TCP connect timeout (ms), minimum 3000 |
readTimeoutMs |
Int | 30000 | Server response timeout (ms), minimum 3000 |
maxUploadAttempts |
Int | 3 | Max upload attempts (initial send + retries), minimum 1 |
retryDelayMs |
[Int] | [5000, 15000] |
Retry delays (ms). Swift only; ObjC uses the default value |
maxCacheSizeMb |
Int | 100 | Upper bound for the sandbox material_monitor/ directory (MB), minimum 10 |
debugPerfLogging |
Bool | false | Enable verbose debug logs. Must be off in production |
let config = MaterialMonitorConfig.Builder()
.baseUploadUrl("https://your-upload-host.com")
.appId(appId)
.appKey(appKey)
.idfa(idfa) // pass in after ATT authorization
.idfv(nil) // SDK reads automatically when nil
.sdkVersion(sdkVersion)
.connectTimeoutMs(15_000)
.readTimeoutMs(30_000)
.maxUploadAttempts(3)
.retryDelayMs([5_000, 15_000])
.debugPerfLogging(false)
.build()
The SDK provides two capture APIs. Choose based on whether the ad has an accessible container view:
| Ad form | Applicable type | Recommended API |
|---|---|---|
| With container | Banner / native ads | collectFrameFromAdContainer |
| Without container | Splash / interstitial / rewarded video | collectFrameFromAdPage |
All capture methods must be called on the main thread. Screenshot capture and upload callbacks are both dispatched on the main thread.
public func collectFrameFromAdContainer(
_ view: UIView,
adInfo: MMAdInfo,
timing: AdTimingInput,
options: FrameOptions,
callback: FrameCallback
)
Parameters:
| Parameter | Type | Description |
|---|---|---|
view |
UIView | Ad container view |
adInfo |
MMAdInfo | Ad information (see 7.4) |
timing |
AdTimingInput | Ad timestamps (see 7.1) |
options |
FrameOptions | Screenshot options (see 7.2) |
callback |
FrameCallback | Capture + auto-upload callback (weak reference, see 6) |
Swift example (Banner):
func onBannerAdShow(_ bannerView: UIView, extra: [String: Any]) {
MaterialMonitor.shared.collectFrameFromAdContainer(
bannerView,
adInfo: buildMMAdInfo(from: extra),
timing: buildTiming(extra),
options: FrameOptions(delayMs: 200), // Banner can use a shorter delay
callback: self
)
}
public func collectFrameFromAdPage(
adInfo: MMAdInfo,
timing: AdTimingInput,
options: FrameOptions,
callback: FrameCallback
)
The SDK will automatically locate the topmost presentedViewController view; if none exists, it captures the keyWindow.
Swift example (Splash):
func splashAdDidShow() {
MaterialMonitor.shared.collectFrameFromAdPage(
adInfo: adInfo,
timing: timing,
options: FrameOptions(delayMs: 3000), // give splash video enough render time, in ms
callback: self
)
}
Re-enqueue an existing FrameRecord (for example when the business side wants delayed upload or cross-process recovery).
public func reportFrameRecord(
_ record: FrameRecord,
adInfo: MMAdInfo,
callback: ReportCallback?
)
Notes:
record must be one previously obtained from collectFrame* and must contain a valid localFilePathshow_id and upload_task_id deduplicationMaterialMonitor.shared.flushPendingUploads()
Usually you do not need to call this manually. The SDK triggers it automatically after successful capture, network recovery, or when retries become due.
// Swift
let records = MaterialMonitor.shared.getRecords(style: .banner)
// ObjC (by adType integer)
NSArray<FrameRecord *> *records = [[MaterialMonitor shared] getRecordsForAdType:2];
Returns all records in non-dead states (pending / uploading / retryWaiting).
MaterialMonitor.shared.deleteRecord(record)
Behavior branches:
| Current state | Behavior |
|---|---|
pending / retryWaiting |
Delete the file pair immediately, no callback |
uploading |
Mark as dead; delete silently after HTTP completes |
dead |
Delete the file pair immediately |
let config = MaterialMonitor.shared.getConfig()
Returns nil when not initialized or after shutdown.
FrameCallback (capture + upload)@objc public protocol FrameCallback: AnyObject {
func onCollectSuccess(_ record: FrameRecord)
func onCollectFailure(errorCode: Int, errorName: String, message: String)
func onReportSuccess(_ record: FrameRecord)
func onReportSkip(_ record: FrameRecord, reason: String)
func onReportFailure(errorCode: Int, errorName: String, message: String, record: FrameRecord?)
}
| Callback | When it fires |
|---|---|
onCollectSuccess |
Screenshot success + persisted to disk + enqueued |
onCollectFailure |
Screenshot or enqueue failure (no later upload callbacks will fire) |
onReportSuccess |
Server returns code=0 |
onReportSkip |
show_id already exists (duplicated_show_id) |
onReportFailure |
Terminal failure such as retries exhausted / auth failure / parameter error / quota exhausted |
ReportCallback (upload only)Used by reportFrameRecord. Signature is the same as the upload portion of FrameCallback.
weak); after the ViewController is released, callbacks are silently dropped and will not leakAdTimingInputpublic init(requestTimeMs: Int64, fillTimeMs: Int64, showTimeMs: Int64)
Timestamps for each ad stage, in milliseconds. Convenience constructor:
let timing = AdTimingInput.sameWallClockMs(Int64(Date().timeIntervalSince1970 * 1000))
FrameOptionspublic init(
delayMs: Int = 500,
maxSizeKb: Int = 300,
maxLongEdge: Int = 1080,
keepQualityFirst: Bool = true,
excludeViews: [UIView] = []
)
| Field | Default | Description |
|---|---|---|
delayMs |
500 | Delay in milliseconds for waiting until the ad creative finishes loading. For video ads, 2000+ is recommended; Banner and native ads can usually use 200 to 300 |
maxSizeKb |
300 | Target maximum JPEG size (KB) |
maxLongEdge |
1080 | Maximum pixel length of the longest JPEG edge |
keepQualityFirst |
true | Whether to prioritize image quality (reduce quality starting from 0.7) |
excludeViews |
[] | Subviews to mask (privacy regions, such as custom password boxes) |
FrameRecordRepresents one capture result. Business code usually reads it only.
| Field | Type | Description |
|---|---|---|
showId |
String | Ad impression ID (server idempotency key) |
uploadTaskId |
String | Internal SDK UUID |
placementId |
String | Ad placement ID |
style |
MaterialStyle | Ad style (Swift) |
adType |
Int | Ad type integer (ObjC-friendly) |
requestTimeMs / fillTimeMs / showTimeMs |
Int64 | Timestamps (ms) |
imageUrl |
String? | Native creative URL (native only) |
localFilePath |
String | Absolute sandbox path |
imageHash |
String | pHash (16-char lowercase hex) |
createdAt |
Int64 | Capture timestamp (ms) |
MMAdInfoAd information, constructed by business code in the onAdShow callback:
| Field | Type | Required | Description |
|---|---|---|---|
requestId |
String | Yes | Request ID |
showId |
String | Yes | Unique ad impression ID (idempotency key) |
placementId |
String | Yes | Ad placement ID |
adSourceId |
String | Yes | Ad source ID |
adNetworkFirmId |
Int | Yes | Ad network ID |
adType |
Int | Yes | Ad type integer: 0=Native / 1=RewardedVideo / 2=Banner / 3=Interstitial / 4=Splash |
requestTimestamp |
Int64 | Yes | Request timestamp (ms) |
fillTimestamp |
Int64 | Yes | Fill timestamp (ms) |
showTimestamp |
Int64 | Yes | Show timestamp (ms) |
creativeId |
String? | Optional | Creative ID (not returned by some ad networks) |
ecpm |
NSNumber? | Optional | eCPM |
sceneId |
String? | Optional | Scene ID |
MaterialStyle| Enum value | adType | rawValue |
|---|---|---|
.native |
0 | "native" |
.rewarded |
1 | "rewarded" |
.banner |
2 | "banner" |
.interstitial |
3 | "interstitial" |
.splash |
4 | "splash" |
MMErrorCode enum (MMErrorCodeXxx prefix in ObjC):
| Numeric value | errorName | Meaning | Retryable |
|---|---|---|---|
| 1001 | VIEW_INVALID |
View is nil or not attached to a window | No |
| 1002 | SCENE_INVALID |
keyWindow not found or top VC already dismissed | No |
| 1003 | COLLECT_FAILED |
Screenshot failed (for example the view was released) | No |
| 1004 | COMPRESS_FAILED |
JPEG compression failed | No |
| Numeric value | errorName | Meaning | Retryable |
|---|---|---|---|
| 2001 | PARAMS_INVALID |
Required parameter missing or invalid | No |
| 2002 | NETWORK_ERROR |
Network request failed / timed out | Yes |
| 2003 | AUTH_FAILED |
Authentication failed (check appId / appKey) | No |
| 2004 | RETRY_EXHAUSTED |
Retry attempts exhausted | No |
| 2005 | UNINITIALIZED |
SDK not initialized or already shut down | No |
| 2006 | NOT_READY |
Upload config incomplete (missing appId / appKey) | No |
| 2007 | STRATEGY_DISABLED |
Current placement disabled by policy | No |
Yes. The SDK internally asserts Thread.isMainThread; calling it from a background thread triggers an assert (crash in DEBUG builds, undefined behavior in Release).
delayMs be set?| Ad type | Recommended delay |
|---|---|
| Banner | 200 ms |
| Interstitial | 500 ms |
| Rewarded video | 1000 ms |
| Splash | 2000~3000 ms (give video splash enough render time) |
| Native | 200~500 ms (business rendering is usually faster) |
record.localFilePath available in the callback?After onCollectSuccess, the sandbox file already exists. After onReportSuccess / onReportFailure, the file may already have been cleaned up. Do not read localFilePath in upload callbacks.
maxCacheSizeMb). When exceeded, cold-start scanning deletes the oldest dead records by createdAtThis is limited by iOS system behavior:
For the last three cases, frame capture from within the app process is not possible on iOS. If you need video creative monitoring, it is recommended to have the server fetch and analyze the creative URL directly from the ad network.
debugPerfLogging be enabled?Enable it only during integration debugging. When enabled, it prints a lot of debug logs via print:
app_id, sign, and meta JSONYes, until the system suspends the app. The SDK uses a normal URLSession and does not support background transfer. If the app is killed, any pending tasks will resume on the next cold start.
The SDK is fully annotated with @objc. You need to:
Bridging-Header.h: #import <MaterialMonitorSDK/MaterialMonitorSDK-Swift.h>MMConfigBuilder@interface MyVC : UIViewController <FrameCallback>import MaterialMonitorSDK
import AppTrackingTransparency
import AdSupport
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [...]?) -> Bool {
// Initialize after ATT authorization (recommended)
if #available(iOS 14, *) {
ATTrackingManager.requestTrackingAuthorization { status in
DispatchQueue.main.async {
self.setupMaterialMonitor()
}
}
} else {
setupMaterialMonitor()
}
return true
}
private func setupMaterialMonitor() {
let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
let config = MaterialMonitorConfig.Builder()
.baseUploadUrl("https://your-upload-host.com")
.appId("your_app_id")
.appKey("your_app_key")
.idfa(idfa)
.sdkVersion("6.5.0")
.debugPerfLogging(false)
.build()
MaterialMonitor.initialize(config: config)
}
}
extension MyViewController: FrameCallback {
func onBannerAdShow(_ bannerView: UIView, extra: [String: Any]) {
let adInfo = buildMMAdInfo(from: extra)
MaterialMonitor.shared.collectFrameFromAdContainer(
bannerView,
adInfo: adInfo,
timing: adInfo.timingInput,
options: FrameOptions(delayMs: 200),
callback: self
)
}
// MARK: FrameCallback
func onCollectSuccess(_ record: FrameRecord) {
print("Success: captured taskId=\(record.uploadTaskId)")
}
func onCollectFailure(errorCode: Int, errorName: String, message: String) {
print("Failure: [\(errorName)] \(message)")
}
func onReportSuccess(_ record: FrameRecord) {
print("Success: reported showId=\(record.showId)")
}
func onReportSkip(_ record: FrameRecord, reason: String) {
print("Skipped: \(reason)")
}
func onReportFailure(errorCode: Int, errorName: String,
message: String, record: FrameRecord?) {
print("Failure: [\(errorName)] \(message)")
}
}
#import <MaterialMonitorSDK/MaterialMonitorSDK-Swift.h>
@interface MyViewController () <FrameCallback>
@end
@implementation MyViewController
- (void)onBannerAdShow:(UIView *)bannerView extra:(NSDictionary *)extra {
MMAdInfo *adInfo = [self buildMMAdInfoFromExtra:extra];
AdTimingInput *timing = [[AdTimingInput alloc]
initWithRequestTimeMs:adInfo.requestTimestamp
fillTimeMs:adInfo.fillTimestamp
showTimeMs:adInfo.showTimestamp];
FrameOptions *options = [[FrameOptions alloc] init];
[[MaterialMonitor shared] collectFrameFromAdContainer:bannerView
adInfo:adInfo
timing:timing
options:options
callback:self];
}
#pragma mark - FrameCallback
- (void)onCollectSuccess:(FrameRecord *)record {
NSLog(@"Success: %@", record.uploadTaskId);
}
- (void)onCollectFailureWithErrorCode:(NSInteger)code
errorName:(NSString *)name
message:(NSString *)msg {
NSLog(@"Failure: [%@] %@", name, msg);
}
- (void)onReportSuccess:(FrameRecord *)record { /* ... */ }
- (void)onReportSkip:(FrameRecord *)record reason:(NSString *)reason { /* ... */ }
- (void)onReportFailureWithErrorCode:(NSInteger)code
errorName:(NSString *)name
message:(NSString *)msg
record:(FrameRecord *)record { /* ... */ }
@end
Native ads (business-side self-rendered) use the same collectFrameFromAdContainer as Banner. Just pass the rendered container view:
func nativeAdDidShow(_ nativeView: UIView) {
MaterialMonitor.shared.collectFrameFromAdContainer(
nativeView,
adInfo: adInfo,
timing: timing,
options: FrameOptions(delayMs: 300), // native creative usually renders a bit slower
callback: self
)
}