// Copyright 2019 Google // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #import "FirebaseSegmentation/Sources/SEGDatabaseManager.h" #import #import "FirebaseCore/Sources/Private/FIRLogger.h" /// SQLite file name. static NSString *const kDatabaseName = @"FirebaseSegmentation.sqlite3"; /// The storage sub-directory that the Segmentation database resides in. static NSString *const kSegmentationStorageSubDirectory = @"Google/FirebaseSegmentation"; /// Column names static NSString *const kMainTableName = @"main"; static NSString *const kMainTableColumnApplicationIdentifier = @"firebase_app_identifier"; static NSString *const kMainTableColumnCustomInstallationIdentifier = @"custom_installation_identifier"; static NSString *const kMainTableColumnFirebaseInstallationIdentifier = @"firebase_installation_identifier"; static NSString *const kMainTableColumnAssociationStatus = @"association_status"; // Exclude the database from iCloud backup. static BOOL SEGAddSkipBackupAttributeToItemAtPath(NSString *filePathString) { NSURL *URL = [NSURL fileURLWithPath:filePathString]; assert([[NSFileManager defaultManager] fileExistsAtPath:URL.path]); NSError *error = nil; BOOL success = [URL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:&error]; if (!success) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000001", @"Error excluding %@ from backup %@.", [URL lastPathComponent], error); } return success; } static BOOL SEGCreateFilePathIfNotExist(NSString *filePath) { if (!filePath.length) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000002", @"Failed to create subdirectory for an empty file path."); return NO; } NSFileManager *fileManager = [NSFileManager defaultManager]; if (![fileManager fileExistsAtPath:filePath]) { NSError *error; [fileManager createDirectoryAtPath:[filePath stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:&error]; if (error) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000003", @"Failed to create subdirectory for database file: %@.", error); return NO; } } return YES; } @implementation SEGDatabaseManager { /// Database storing all the config information. sqlite3 *_database; /// Serial queue for database read/write operations. dispatch_queue_t _databaseOperationQueue; } + (instancetype)sharedInstance { static dispatch_once_t onceToken; static SEGDatabaseManager *sharedInstance; dispatch_once(&onceToken, ^{ sharedInstance = [[SEGDatabaseManager alloc] init]; }); return sharedInstance; } - (instancetype)init { self = [super init]; if (self) { _databaseOperationQueue = dispatch_queue_create("com.google.firebasesegmentation.database", DISPATCH_QUEUE_SERIAL); } return self; } #pragma mark - Public Methods - (void)loadMainTableWithCompletion:(SEGRequestCompletion)completionHandler { dispatch_async(_databaseOperationQueue, ^{ // Read the database into memory. NSDictionary *> *associations = [self loadMainTable]; if (associations != nil) { completionHandler(YES, associations); } else { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000004", @"Failed to load main table."); completionHandler(NO, @{}); } }); return; } - (void)createOrOpenDatabaseWithCompletion:(SEGRequestCompletion)completionHandler { dispatch_async(_databaseOperationQueue, ^{ NSString *dbPath = [SEGDatabaseManager pathForSegmentationDatabase]; FIRLogDebug(kFIRLoggerSegmentation, @"I-SEG000005", @"Loading segmentation database at path %@", dbPath); const char *databasePath = dbPath.UTF8String; // Create or open database path. if (!SEGCreateFilePathIfNotExist(dbPath)) { completionHandler(NO, @{kSEGErrorDescription : @"Could not create database file at path"}); } int flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FILEPROTECTION_COMPLETE | SQLITE_OPEN_FULLMUTEX; if (sqlite3_open_v2(databasePath, &self->_database, flags, NULL) == SQLITE_OK) { // Create table if does not exist already. if ([self createTableSchema]) { // DB file created or already exists. // Exclude the app data used from iCloud backup. SEGAddSkipBackupAttributeToItemAtPath(dbPath); // Read the database into memory. NSDictionary *associations = [self loadMainTable]; completionHandler(YES, associations); } else { // Remove database before fail. [self removeDatabase:dbPath]; FIRLogError(kFIRLoggerSegmentation, @"I-SEG00006", @"Failed to create table."); // Create a new database if existing database file is corrupted. if (!SEGCreateFilePathIfNotExist(dbPath)) { NSString *description = [NSString stringWithFormat:@"Could not recreate database file at path: %@", dbPath]; completionHandler(NO, @{kSEGErrorDescription : description}); return; } // Try to open the database with the new file. if (sqlite3_open_v2(databasePath, &self->_database, flags, NULL) == SQLITE_OK) { if (![self createTableSchema]) { // Remove database before fail. [self removeDatabase:dbPath]; // If it failed again, there's nothing we can do here. FIRLogError(kFIRLoggerSegmentation, @"I-SEG00007", @"Failed to create table."); completionHandler(NO, @{kSEGErrorDescription : @"Failed to re-open new database file"}); } else { // Exclude the app data used from iCloud backup. SEGAddSkipBackupAttributeToItemAtPath(dbPath); // Skip reading the db into memory, since it's empty. completionHandler(YES, @{}); } } else { [self logDatabaseError]; completionHandler(NO, @{kSEGErrorDescription : @"Could not create database."}); } } } else { [self logDatabaseError]; completionHandler(NO, @{kSEGErrorDescription : @"Error creating database."}); } }); } - (void)removeDatabase:(NSString *)path completion:(SEGRequestCompletion)completionHandler { dispatch_async(_databaseOperationQueue, ^{ [self removeDatabase:path]; completionHandler(YES, nil); }); } #pragma mark - Private Methods - (NSDictionary *)loadMainTable { NSString *SQLQuery = [NSString stringWithFormat:@"SELECT %@, %@, %@, %@ FROM %@", kMainTableColumnApplicationIdentifier, kMainTableColumnCustomInstallationIdentifier, kMainTableColumnFirebaseInstallationIdentifier, kMainTableColumnAssociationStatus, kMainTableName]; sqlite3_stmt *statement = [self prepareSQL:[SQLQuery cStringUsingEncoding:NSUTF8StringEncoding]]; if (!statement) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG00008", @"Failed to create sqlite statement with query: %@.", SQLQuery); return nil; } NSMutableDictionary *> *associations = [[NSMutableDictionary alloc] init]; while (sqlite3_step(statement) == SQLITE_ROW) { NSString *firebaseApplicationName = [[NSString alloc] initWithUTF8String:(char *)sqlite3_column_text(statement, 0)]; NSString *customInstallationIdentifier = [[NSString alloc] initWithUTF8String:(char *)sqlite3_column_text(statement, 1)]; NSString *firebaseInstallationIdentifier = [[NSString alloc] initWithUTF8String:(char *)sqlite3_column_text(statement, 2)]; NSString *associationStatus = [[NSString alloc] initWithUTF8String:(char *)sqlite3_column_text(statement, 3)]; NSDictionary *associationData = @{ kSEGCustomInstallationIdentifierKey : customInstallationIdentifier, kSEGFirebaseInstallationIdentifierKey : firebaseInstallationIdentifier, kSEGAssociationStatusKey : associationStatus }; [associations setObject:associationData forKey:firebaseApplicationName]; } sqlite3_finalize(statement); return associations; } /// Returns the current version of the Remote Config database. + (NSString *)pathForSegmentationDatabase { #if TARGET_OS_TV NSArray *dirPaths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); #else NSArray *dirPaths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES); #endif NSString *storageDir = dirPaths.firstObject; NSArray *components = @[ storageDir, kSegmentationStorageSubDirectory, kDatabaseName ]; return [NSString pathWithComponents:components]; } - (BOOL)createTableSchema { SEG_MUST_NOT_BE_MAIN_THREAD(); NSString *mainTableSchema = [NSString stringWithFormat:@"create TABLE IF NOT EXISTS %@ (_id INTEGER PRIMARY KEY, %@ " @"TEXT, %@ TEXT, %@ TEXT, %@ TEXT)", kMainTableName, kMainTableColumnApplicationIdentifier, kMainTableColumnCustomInstallationIdentifier, kMainTableColumnFirebaseInstallationIdentifier, kMainTableColumnAssociationStatus]; return [self executeQuery:[mainTableSchema cStringUsingEncoding:NSUTF8StringEncoding]]; } - (void)removeDatabase:(NSString *)path { SEG_MUST_NOT_BE_MAIN_THREAD(); if (sqlite3_close(self->_database) != SQLITE_OK) { [self logDatabaseError]; } self->_database = nil; NSFileManager *fileManager = [NSFileManager defaultManager]; NSError *error; if (![fileManager removeItemAtPath:path error:&error]) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000011", @"Failed to remove database at path %@ for error %@.", path, error); } } #pragma mark - execute - (BOOL)executeQuery:(const char *)SQL { SEG_MUST_NOT_BE_MAIN_THREAD(); char *error; if (sqlite3_exec(_database, SQL, nil, nil, &error) != SQLITE_OK) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000012", @"Failed to execute query with error %s.", error); return NO; } return YES; } #pragma mark - insert - (void)insertMainTableApplicationNamed:(NSString *)firebaseApplication customInstanceIdentifier:(NSString *)customInstanceIdentifier firebaseInstanceIdentifier:(NSString *)firebaseInstanceIdentifier associationStatus:(NSString *)associationStatus completionHandler:(SEGRequestCompletion)handler { // TODO: delete the row first. dispatch_async(_databaseOperationQueue, ^{ NSArray *values = @[ firebaseApplication, customInstanceIdentifier, firebaseInstanceIdentifier, associationStatus ]; BOOL success = [self insertMainTableWithValues:values]; if (handler) { dispatch_async(dispatch_get_main_queue(), ^{ handler(success, nil); }); } }); } - (BOOL)insertMainTableWithValues:(NSArray *)values { SEG_MUST_NOT_BE_MAIN_THREAD(); if (values.count != 4) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000013", @"Failed to insert config record. Wrong number of give parameters, current " @"number is %ld, correct number is 4.", (long)values.count); return NO; } NSString *SQL = [NSString stringWithFormat:@"INSERT INTO %@ (%@, %@, %@, %@) values (?, ?, ?, ?)", kMainTableName, kMainTableColumnApplicationIdentifier, kMainTableColumnCustomInstallationIdentifier, kMainTableColumnFirebaseInstallationIdentifier, kMainTableColumnAssociationStatus]; sqlite3_stmt *statement = [self prepareSQL:[SQL UTF8String]]; if (!statement) { return NO; } NSString *aString = values[0]; if (![self bindStringToStatement:statement index:1 string:aString]) { return [self logErrorWithSQL:[SQL UTF8String] finalizeStatement:statement returnValue:NO]; } aString = values[1]; if (![self bindStringToStatement:statement index:2 string:aString]) { return [self logErrorWithSQL:[SQL UTF8String] finalizeStatement:statement returnValue:NO]; } aString = values[2]; if (![self bindStringToStatement:statement index:3 string:aString]) { return [self logErrorWithSQL:[SQL UTF8String] finalizeStatement:statement returnValue:NO]; } aString = values[3]; if (![self bindStringToStatement:statement index:4 string:aString]) { return [self logErrorWithSQL:[SQL UTF8String] finalizeStatement:statement returnValue:NO]; } if (sqlite3_step(statement) != SQLITE_DONE) { return [self logErrorWithSQL:[SQL UTF8String] finalizeStatement:statement returnValue:NO]; } sqlite3_finalize(statement); return YES; } /// TODO: (Check if required). Clear the record of given namespace and package name /// before updating the table. - (void)deleteRecordFromMainTableWithCustomInstanceIdentifier: (nonnull NSString *)customInstanceIdentifier { } /// TODO: (Check if required). Remove all the records from a config content table. - (void)deleteAllRecordsFromTable { } #pragma mark - helper - (BOOL)executeQuery:(const char *)SQL withParams:(NSArray *)params { SEG_MUST_NOT_BE_MAIN_THREAD(); sqlite3_stmt *statement = [self prepareSQL:SQL]; if (!statement) { return NO; } [self bindStringsToStatement:statement stringArray:params]; if (sqlite3_step(statement) != SQLITE_DONE) { return [self logErrorWithSQL:SQL finalizeStatement:statement returnValue:NO]; } sqlite3_finalize(statement); return YES; } /// Params only accept TEXT format string. - (BOOL)bindStringsToStatement:(sqlite3_stmt *)statement stringArray:(NSArray *)array { int index = 1; for (NSString *param in array) { if (![self bindStringToStatement:statement index:index string:param]) { return [self logErrorWithSQL:nil finalizeStatement:statement returnValue:NO]; } index++; } return YES; } - (BOOL)bindStringToStatement:(sqlite3_stmt *)statement index:(int)index string:(NSString *)value { if (sqlite3_bind_text(statement, index, [value UTF8String], -1, SQLITE_TRANSIENT) != SQLITE_OK) { return [self logErrorWithSQL:nil finalizeStatement:statement returnValue:NO]; } return YES; } - (sqlite3_stmt *)prepareSQL:(const char *)SQL { sqlite3_stmt *statement = nil; if (sqlite3_prepare_v2(_database, SQL, -1, &statement, NULL) != SQLITE_OK) { [self logErrorWithSQL:SQL finalizeStatement:statement returnValue:NO]; return nil; } return statement; } - (NSString *)errorMessage { return [NSString stringWithFormat:@"%s", sqlite3_errmsg(_database)]; } - (int)errorCode { return sqlite3_errcode(_database); } - (void)logDatabaseError { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000015", @"Error message: %@. Error code: %d.", [self errorMessage], [self errorCode]); } - (BOOL)logErrorWithSQL:(const char *)SQL finalizeStatement:(sqlite3_stmt *)statement returnValue:(BOOL)returnValue { if (SQL) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000016", @"Failed with SQL: %s.", SQL); } else { const char *sqlString = sqlite3_sql(statement); NSString *sql; if (sqlString != NULL) { sql = [NSString stringWithCString:sqlString encoding:NSUTF8StringEncoding]; } if (sql) { FIRLogError(kFIRLoggerSegmentation, @"I-SEG000016", @"Failed with SQL: %s.", SQL); } } [self logDatabaseError]; if (statement) { sqlite3_finalize(statement); } return returnValue; } @end