527 lines
19 KiB
Objective-C
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
|