// // WRReaderPencilNoteManager.m // WeRead (微信读书) // Reverse-engineered implementation reconstruction // // 11 class methods identified from binary. Manages Apple Pencil // annotations with local storage and Tencent Cloud COS uploads. // // Architecture notes: // - All methods are class methods (static utility pattern). // - Drawings are stored as both raw PKDrawing data and rendered PNG images. // - Draft vs. published states allow users to save work-in-progress. // - Uploads go to Tencent Cloud COS (Cloud Object Storage) via // signed URLs or direct API. // - File paths follow a predictable pattern: {directory}/{reviewItemId}_{reviewId}.{ext} // #import "WRReaderPencilNoteManager.h" #import // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- static NSString *const kPencilDrawingDir = @"pencil_drawings"; static NSString *const kPencilImageDir = @"pencil_images"; static NSString *const kCOSUploadURL = @"https://weread-1258476243.file.myqcloud.com"; static NSString *const kDraftSuffix = @"_draft"; static NSString *const kPublishedSuffix = @""; #pragma mark - Implementation @implementation WRReaderPencilNoteManager #pragma mark - Drawing Existence Check + (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId reviewId:(NSString *)reviewId isDraft:(BOOL)isDraft { // Check for the drawing data file NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId reviewId:reviewId isDraft:isDraft]; BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:dataPath]; if (!exists) { // Also check for the image file NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId reviewId:reviewId]; exists = [[NSFileManager defaultManager] fileExistsAtPath:imagePath]; } return exists; } #pragma mark - Delete Operations + (void)deleteAllReviewDrawings { // Remove the entire pencil drawings directory NSString *drawingDir = [self drawingFileDirectory]; NSString *imageDir = [self _pencilImageDirectory]; NSFileManager *fm = [NSFileManager defaultManager]; // Remove drawing data [fm removeItemAtPath:drawingDir error:nil]; [fm removeItemAtPath:imageDir error:nil]; // Recreate empty directories [fm createDirectoryAtPath:drawingDir withIntermediateDirectories:YES attributes:nil error:nil]; [fm createDirectoryAtPath:imageDir withIntermediateDirectories:YES attributes:nil error:nil]; NSLog(@"[WRReaderPencilNoteManager] Deleted all review drawings"); } + (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId reviewId:(NSString *)reviewId isDraft:(BOOL)isDraft { NSFileManager *fm = [NSFileManager defaultManager]; // Delete drawing data file NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId reviewId:reviewId isDraft:isDraft]; [fm removeItemAtPath:dataPath error:nil]; // Delete image file (only for published, drafts may not have images) if (!isDraft) { NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId reviewId:reviewId]; [fm removeItemAtPath:imagePath error:nil]; } NSLog(@"[WRReaderPencilNoteManager] Deleted drawing: %@ (draft=%d)", reviewItemId, isDraft); } #pragma mark - Download + (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl desPath:(NSString *)destPath callback:(WRPencilDownloadCallback)callback { if (!cosUrl || cosUrl.length == 0) { if (callback) { callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Empty COS URL"}]); } return; } NSURL *url = [NSURL URLWithString:cosUrl]; if (!url) { if (callback) { callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager" code:-2 userInfo:@{NSLocalizedDescriptionKey: @"Invalid COS URL"}]); } return; } // Download from COS NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; config.timeoutIntervalForRequest = 30; NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error || !data) { NSLog(@"[WRReaderPencilNoteManager] COS download failed: %@", error.localizedDescription); if (callback) callback(NO, nil, error); return; } // Verify HTTP status NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; if (httpResponse.statusCode != 200) { NSError *statusError = [NSError errorWithDomain:@"WRReaderPencilNoteManager" code:httpResponse.statusCode userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"HTTP %ld", (long)httpResponse.statusCode]}]; if (callback) callback(NO, nil, statusError); return; } // Save to local destination if (destPath) { NSString *destDir = [destPath stringByDeletingLastPathComponent]; [[NSFileManager defaultManager] createDirectoryAtPath:destDir withIntermediateDirectories:YES attributes:nil error:nil]; NSError *writeError = nil; [data writeToFile:destPath options:NSDataWritingAtomic error:&writeError]; if (writeError) { NSLog(@"[WRReaderPencilNoteManager] Write failed: %@", writeError); } } if (callback) callback(YES, data, nil); }]; [task resume]; } #pragma mark - File Paths + (NSString *)drawingFileDirectory { static NSString *sDir = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSString *caches = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES).firstObject; sDir = [caches stringByAppendingPathComponent:kPencilDrawingDir]; // Create directory if needed [[NSFileManager defaultManager] createDirectoryAtPath:sDir withIntermediateDirectories:YES attributes:nil error:nil]; }); return sDir; } + (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId reviewId:(NSString *)reviewId isDraft:(BOOL)isDraft { NSString *dir = [self drawingFileDirectory]; NSString *suffix = isDraft ? kDraftSuffix : kPublishedSuffix; // Filename: {reviewItemId}_{reviewId}{suffix}.drawing NSString *filename = [NSString stringWithFormat:@"%@_%@%@.drawing", reviewItemId, reviewId, suffix]; return [dir stringByAppendingPathComponent:filename]; } + (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId reviewId:(NSString *)reviewId { NSString *dir = [self _pencilImageDirectory]; // Filename: {reviewItemId}_{reviewId}.png NSString *filename = [NSString stringWithFormat:@"%@_%@.png", reviewItemId, reviewId]; return [dir stringByAppendingPathComponent:filename]; } #pragma mark - Upload + (void)uploadPencilDrawing:(UIImage *)drawing colorStyle:(WRPencilColorStyle)colorStyle onlyUploadImage:(BOOL)uploadImage canRetry:(BOOL)canRetry { if (!drawing) return; // 1. Render the image to PNG data NSData *imageData = UIImagePNGRepresentation(drawing); if (!imageData) return; // 2. Build the upload request to COS // // Tencent Cloud COS upload flow: // a. Request a signed upload URL from WeRead's backend // b. PUT the file directly to COS using the signed URL // // For simplicity, we'll show the direct upload pattern. NSString *filename = [NSString stringWithFormat:@"pencil_%@_%ld.png", [[NSUUID UUID] UUIDString], (long)[[NSDate date] timeIntervalSince1970]]; NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@", kCOSUploadURL, filename]; NSURL *uploadURL = [NSURL URLWithString:uploadURLStr]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL]; [request setHTTPMethod:@"PUT"]; [request addValue:@"image/png" forHTTPHeaderField:@"Content-Type"]; [request addValue:@(imageData.length).stringValue forHTTPHeaderField:@"Content-Length"]; NSURLSession *session = [NSURLSession sharedSession]; NSURLSessionUploadTask *task = [session uploadTaskWithRequest:request fromData:imageData completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error) { NSLog(@"[WRReaderPencilNoteManager] Upload failed: %@", error.localizedDescription); if (canRetry) { // Queue for retry [self _queueRetryUpload:imageData filename:filename]; } return; } NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; if (httpResponse.statusCode == 200 || httpResponse.statusCode == 201) { NSLog(@"[WRReaderPencilNoteManager] Upload success: %@", filename); // Notify the backend about the uploaded file [self _notifyBackendOfFileUpload:uploadURLStr colorStyle:colorStyle]; } }]; [task resume]; // 3. Also upload the raw drawing data if requested if (!uploadImage) { // The drawing data (PKDrawing serialized) needs separate upload // This is handled by uploadPencilNoteData:suffix: } } + (void)uploadPencilNoteData:(NSData *)noteData suffix:(NSString *)suffix { if (!noteData || noteData.length == 0) return; NSString *filename = [NSString stringWithFormat:@"pencil_data_%@_%@.%@", [[NSUUID UUID] UUIDString], @((long)[[NSDate date] timeIntervalSince1970]), suffix ?: @"drawing"]; NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@", kCOSUploadURL, filename]; NSURL *uploadURL = [NSURL URLWithString:uploadURLStr]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL]; [request setHTTPMethod:@"PUT"]; [request addValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"]; [request addValue:@(noteData.length).stringValue forHTTPHeaderField:@"Content-Length"]; NSURLSessionUploadTask *task = [[NSURLSession sharedSession] uploadTaskWithRequest:request fromData:noteData completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error) { NSLog(@"[WRReaderPencilNoteManager] Data upload failed: %@", error.localizedDescription); return; } NSLog(@"[WRReaderPencilNoteManager] Data upload success: %@", filename); }]; [task resume]; } #pragma mark - Local Storage + (void)writeDrawingDataToLocal:(NSData *)data reviewItemId:(NSString *)reviewItemId reviewId:(NSString *)reviewId isDraft:(BOOL)isDraft { if (!data || !reviewItemId || !reviewId) return; NSString *path = [self drawingFilePathWithReviewItemId:reviewItemId reviewId:reviewId isDraft:isDraft]; // Ensure directory exists NSString *dir = [path stringByDeletingLastPathComponent]; [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; NSError *error = nil; [data writeToFile:path options:NSDataWritingAtomic error:&error]; if (error) { NSLog(@"[WRReaderPencilNoteManager] Write drawing data failed: %@", error); } } + (void)writeDrawingToLocal:(UIImage *)drawing reviewItemId:(NSString *)reviewItemId reviewId:(NSString *)reviewId isDraft:(BOOL)isDraft { if (!drawing || !reviewItemId || !reviewId) return; NSString *path = [self imageFilePathWithReviewItemId:reviewItemId reviewId:reviewId]; // Ensure directory exists NSString *dir = [path stringByDeletingLastPathComponent]; [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; NSData *pngData = UIImagePNGRepresentation(drawing); if (pngData) { NSError *error = nil; [pngData writeToFile:path options:NSDataWritingAtomic error:&error]; if (error) { NSLog(@"[WRReaderPencilNoteManager] Write drawing image failed: %@", error); } } } #pragma mark - Additional Methods + (NSUInteger)totalDrawingStorageSize { NSFileManager *fm = [NSFileManager defaultManager]; NSUInteger totalSize = 0; // Calculate size of drawing data directory totalSize += [self _directorySize:[self drawingFileDirectory]]; // Calculate size of image directory totalSize += [self _directorySize:[self _pencilImageDirectory]]; return totalSize; } + (NSArray *)allStoredDrawingReviewItemIds { NSString *drawingDir = [self drawingFileDirectory]; NSFileManager *fm = [NSFileManager defaultManager]; NSError *error = nil; NSArray *files = [fm contentsOfDirectoryAtPath:drawingDir error:&error]; if (!files) return @[]; NSMutableSet *reviewItemIds = [NSMutableSet set]; for (NSString *filename in files) { // Filename format: {reviewItemId}_{reviewId}[_draft].drawing NSArray *components = [filename componentsSeparatedByString:@"_"]; if (components.count >= 1) { [reviewItemIds addObject:components[0]]; } } return [reviewItemIds allObjects]; } + (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData scale:(CGFloat)scale { if (!drawingData || drawingData.length == 0) return nil; // Attempt to deserialize as PKDrawing if (@available(iOS 13.0, *)) { NSError *error = nil; PKDrawing *drawing = [[PKDrawing alloc] initWithData:drawingData error:&error]; if (drawing) { CGRect bounds = drawing.bounds; if (scale <= 0) scale = [UIScreen mainScreen].scale; UIImage *image = [drawing imageFromRect:bounds scale:scale]; return image; } } // Fallback: try UIImage from data directly return [UIImage imageWithData:drawingData]; } #pragma mark - Private Helpers + (NSString *)_pencilImageDirectory { static NSString *sDir = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSString *caches = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES).firstObject; sDir = [caches stringByAppendingPathComponent:kPencilImageDir]; [[NSFileManager defaultManager] createDirectoryAtPath:sDir withIntermediateDirectories:YES attributes:nil error:nil]; }); return sDir; } + (NSUInteger)_directorySize:(NSString *)path { NSFileManager *fm = [NSFileManager defaultManager]; BOOL isDir = NO; if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0; if (!isDir) { NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil]; return [attrs fileSize]; } NSUInteger totalSize = 0; NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path]; for (NSString *filename in enumerator) { NSString *filePath = [path stringByAppendingPathComponent:filename]; NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil]; totalSize += [attrs fileSize]; } return totalSize; } + (void)_queueRetryUpload:(NSData *)imageData filename:(NSString *)filename { // Store failed upload in a retry queue NSMutableArray *queue = [[[NSUserDefaults standardUserDefaults] arrayForKey:@"pencil_upload_retry_queue"] mutableCopy] ?: [NSMutableArray array]; NSDictionary *entry = @{ @"filename" : filename ?: @"", @"timestamp" : @((long)[[NSDate date] timeIntervalSince1970]), // Note: imageData is too large for UserDefaults; would use file-based queue }; [queue addObject:entry]; [[NSUserDefaults standardUserDefaults] setObject:queue forKey:@"pencil_upload_retry_queue"]; [[NSUserDefaults standardUserDefaults] synchronize]; // Write the actual image data to a retry file NSString *retryDir = [[self drawingFileDirectory] stringByAppendingPathComponent:@"retry"]; [[NSFileManager defaultManager] createDirectoryAtPath:retryDir withIntermediateDirectories:YES attributes:nil error:nil]; NSString *retryPath = [retryDir stringByAppendingPathComponent:filename]; [imageData writeToFile:retryPath atomically:YES]; } + (void)_notifyBackendOfFileUpload:(NSString *)fileURL colorStyle:(WRPencilColorStyle)colorStyle { // Notify WeRead's backend that a pencil drawing has been uploaded to COS. // The backend will associate it with the review/note. // // This would typically be a POST to an API endpoint with the COS URL. NSLog(@"[WRReaderPencilNoteManager] Notified backend of upload: %@ (style=%ld)", fileURL, (long)colorStyle); } @end