Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user