ReadViewSDK/Doc/WXRead/decompiled/WRReaderPencilNoteManager.m

527 lines
19 KiB
Objective-C

//
// 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 <PencilKit/PencilKit.h>
// ---------------------------------------------------------------------------
// 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<NSString *> *)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