// // WRPreloadBookManager.m // WeRead (微信读书) // Reverse-engineered implementation reconstruction // // Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary), // 23 known methods, and contextual knowledge of WeRead's preload strategy. // // Architecture notes: // - Class methods handle key/filename storage (shared state via NSUserDefaults // or a static dictionary). // - Instance methods handle the actual preloading logic. // - Preloading decisions are based on book ranking, reading patterns, // and network conditions (WiFi vs. cellular). // - Preloaded content is encrypted and stored in a separate cache directory. // #import "WRPreloadBookManager.h" #import "WRBookNetwork.h" #import "WREncryptedFileManager.h" // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- static NSString *const kPreloadEncryptKeyPrefix = @"preload_key_"; static NSString *const kPreloadFileNamePrefix = @"preload_fn_"; static NSString *const kPreloadCacheDir = @"preload_cache"; // Preload limits static const NSUInteger kMaxPreloadChaptersOnWiFi = 50; static const NSUInteger kMaxPreloadChaptersOnCellular = 5; static const NSUInteger kMaxPreloadBooksOnShelf = 3; #pragma mark - Private Interface @interface WRPreloadBookManager () @property (nonatomic, strong, readwrite) NSMutableDictionary *preloadState; @end #pragma mark - Implementation @implementation WRPreloadBookManager { // Ivar from binary analysis: NSMutableDictionary *_preloadState; } #pragma mark - Lifecycle - (instancetype)init { self = [super init]; if (self) { _preloadState = [NSMutableDictionary dictionary]; // Register for network change notifications to adjust preload behavior [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_networkStatusChanged:) name:@"WRNetworkStatusChangedNotification" object:nil]; } return self; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } #pragma mark - Class Methods: Encryption Key Storage + (void)saveEncryptKey:(NSString *)key forPath:(NSString *)path bookId:(NSString *)bookId { if (!key || !bookId) return; // Store in NSUserDefaults with a composite key NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@", kPreloadEncryptKeyPrefix, bookId, [path lastPathComponent]]; [[NSUserDefaults standardUserDefaults] setObject:key forKey:storageKey]; [[NSUserDefaults standardUserDefaults] synchronize]; // Also save to the keychain via WREncryptedFileManager for persistence NSData *keyData = [key dataUsingEncoding:NSUTF8StringEncoding]; [WREncryptedFileManager saveKey:keyData forBookId:bookId]; } + (nullable NSString *)encryptKeyForPath:(NSString *)path bookId:(NSString *)bookId { NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@", kPreloadEncryptKeyPrefix, bookId, [path lastPathComponent]]; NSString *key = [[NSUserDefaults standardUserDefaults] stringForKey:storageKey]; // Fallback: try the keychain if (!key) { NSData *keyData = [WREncryptedFileManager keyForBookId:bookId]; if (keyData) { key = [[NSString alloc] initWithData:keyData encoding:NSUTF8StringEncoding]; } } return key; } + (void)removeEncryptKeyForPath:(NSString *)path bookId:(NSString *)bookId { NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@", kPreloadEncryptKeyPrefix, bookId, [path lastPathComponent]]; [[NSUserDefaults standardUserDefaults] removeObjectForKey:storageKey]; [[NSUserDefaults standardUserDefaults] synchronize]; } #pragma mark - Class Methods: Filename Dictionary + (void)saveFileNameDict:(NSDictionary *)dict bookId:(NSString *)bookId { if (!dict || !bookId) return; NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId]; [[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey]; [[NSUserDefaults standardUserDefaults] synchronize]; } + (nullable NSString *)fileNameForKey:(NSString *)key bookId:(NSString *)bookId { NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId]; NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:storageKey]; return dict[key]; } + (void)removeFileNameForKey:(NSString *)key bookId:(NSString *)bookId { NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId]; NSMutableDictionary *dict = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:storageKey] mutableCopy]; if (dict) { [dict removeObjectForKey:key]; [[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey]; [[NSUserDefaults standardUserDefaults] synchronize]; } } #pragma mark - Class Methods: Cache Management + (void)clearKV { // Remove all preload-related keys from NSUserDefaults NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation]; NSArray *prefixes = @[kPreloadEncryptKeyPrefix, kPreloadFileNamePrefix]; for (NSString *key in defaults) { for (NSString *prefix in prefixes) { if ([key hasPrefix:prefix]) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; } } } [[NSUserDefaults standardUserDefaults] synchronize]; } + (void)removeKVWithBookId:(NSString *)bookId { if (!bookId) return; NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation]; NSString *keyPrefix1 = [NSString stringWithFormat:@"%@%@", kPreloadEncryptKeyPrefix, bookId]; NSString *keyPrefix2 = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId]; for (NSString *key in defaults) { if ([key hasPrefix:keyPrefix1] || [key hasPrefix:keyPrefix2]) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; } } [[NSUserDefaults standardUserDefaults] synchronize]; } #pragma mark - Instance Methods: Preloading - (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene { // 1. Determine the maximum chapters to preload based on scene NSUInteger maxChapters; switch (scene) { case WRPreloadSceneWiFi: maxChapters = kMaxPreloadChaptersOnWiFi; break; case WRPreloadSceneShelf: maxChapters = 20; // Moderate for shelf browsing break; case WRPreloadSceneReading: maxChapters = 10; // Next few chapters while reading break; default: maxChapters = kMaxPreloadChaptersOnCellular; break; } // 2. Get the chapter list for the book NSArray *chapters = book.chapters; if (!chapters || chapters.count == 0) { NSLog(@"[WRPreloadBookManager] No chapters for book %@", book.bookId); return; } // 3. Select chapters to preload NSArray *uidsToPreload = [self _selectChaptersForPreload:book maxCount:maxChapters scene:scene]; if (uidsToPreload.count == 0) return; // 4. Update preload state NSString *bookId = book.bookId; _preloadState[bookId] = @{ @"status" : @"preloading", @"scene" : @(scene), @"totalCount" : @(uidsToPreload.count), @"loadedCount" : @(0), @"startTime" : @([[NSDate date] timeIntervalSince1970]), }; // 5. Initiate download via WRBookNetwork [WRBookNetwork loadTarForEpubBookId:bookId chapters:uidsToPreload isPreload:YES]; NSLog(@"[WRPreloadBookManager] Started preloading %lu chapters for book %@ (scene=%ld)", (unsigned long)uidsToPreload.count, bookId, (long)scene); } - (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx { // Preload chapters starting from the given index NSArray *chapters = book.chapters; if (!chapters || fromChapterIdx >= (NSInteger)chapters.count) return; // Determine how many chapters to preload ahead NSUInteger preloadCount = 5; // Default: preload 5 chapters ahead if ([self _isOnWiFi]) { preloadCount = 10; } NSInteger startIndex = MAX(0, fromChapterIdx); NSInteger endIndex = MIN(startIndex + (NSInteger)preloadCount, (NSInteger)chapters.count); NSMutableArray *uids = [NSMutableArray array]; for (NSInteger i = startIndex; i < endIndex; i++) { NSDictionary *chapter = chapters[i]; NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"]; if (uid) [uids addObject:uid]; } if (uids.count > 0) { [self downloadBook:book uids:uids]; } } - (void)downloadBook:(WRBook *)book uids:(NSArray *)uids { if (!book.bookId || uids.count == 0) return; // Filter out already-downloaded chapters NSMutableArray *pendingUIDs = [NSMutableArray array]; NSString *plainDir = [self _plainBookDirectoryForBookId:book.bookId]; for (NSString *uid in uids) { NSString *chapterPath = [plainDir stringByAppendingPathComponent: [NSString stringWithFormat:@"chapter_%@.xhtml", uid]]; if (![[NSFileManager defaultManager] fileExistsAtPath:chapterPath]) { [pendingUIDs addObject:uid]; } } if (pendingUIDs.count == 0) return; // Initiate download [WRBookNetwork loadTarForEpubBookId:book.bookId chapters:pendingUIDs isPreload:YES]; } - (void)cleanUpPreloadBook { // 1. Remove all preloaded book files NSString *preloadDir = [self _preloadCacheDirectory]; NSFileManager *fm = [NSFileManager defaultManager]; NSError *error = nil; [fm removeItemAtPath:preloadDir error:&error]; if (error) { NSLog(@"[WRPreloadBookManager] Cleanup failed: %@", error); } // 2. Clear all key-value caches [[self class] clearKV]; // 3. Reset state [_preloadState removeAllObjects]; // 4. Recreate the directory [fm createDirectoryAtPath:preloadDir withIntermediateDirectories:YES attributes:nil error:nil]; } - (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion onlyCalc:(BOOL)onlyCalc { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString *preloadDir = [self _preloadCacheDirectory]; NSUInteger totalSize = [self _directorySizeAtPath:preloadDir]; if (!onlyCalc) { // Clear the cache [self cleanUpPreloadBook]; } dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(totalSize); }); }); } #pragma mark - Additional Methods (Reconstructed) - (BOOL)isPreloadingBookId:(NSString *)bookId { NSDictionary *state = _preloadState[bookId]; return [state[@"status"] isEqualToString:@"preloading"]; } - (float)preloadProgressForBookId:(NSString *)bookId { NSDictionary *state = _preloadState[bookId]; if (!state) return 0.0; NSInteger total = [state[@"totalCount"] integerValue]; NSInteger loaded = [state[@"loadedCount"] integerValue]; if (total <= 0) return 0.0; return (float)loaded / (float)total; } - (void)cancelPreloadForBookId:(NSString *)bookId { // Mark the preload as cancelled in state _preloadState[bookId] = @{ @"status" : @"cancelled", @"cancelTime": @([[NSDate date] timeIntervalSince1970]), }; // Note: Actual network cancellation would need to be handled // by WRBookNetwork's task management NSLog(@"[WRPreloadBookManager] Cancelled preload for book %@", bookId); } - (NSArray *)predictedChapterUidsForBook:(WRBook *)book { // Predict which chapters the user is likely to read next. // // Strategy: // 1. If the user is currently reading chapter N, predict N+1, N+2, ... // 2. If the user has a pattern of jumping (e.g., to bookmarks), preload those // 3. Consider book ranking: popular books get more aggressive preloading NSMutableArray *predicted = [NSMutableArray array]; NSArray *chapters = book.chapters; if (!chapters) return predicted; // Find the current chapter index NSInteger currentIndex = 0; NSString *currentUid = book.currentChapterUid; for (NSUInteger i = 0; i < chapters.count; i++) { NSDictionary *chapter = chapters[i]; if ([chapter[@"chapterUid"] isEqualToString:currentUid]) { currentIndex = (NSInteger)i; break; } } // Predict the next N chapters NSUInteger predictCount = [self _isOnWiFi] ? 10 : 3; for (NSInteger i = currentIndex + 1; i < MIN(currentIndex + 1 + (NSInteger)predictCount, (NSInteger)chapters.count); i++) { NSDictionary *chapter = chapters[i]; NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"]; if (uid) [predicted addObject:uid]; } return [predicted copy]; } #pragma mark - Private Helpers - (NSArray *)_selectChaptersForPreload:(WRBook *)book maxCount:(NSUInteger)maxCount scene:(WRPreloadScene)scene { NSArray *chapters = book.chapters; if (!chapters) return @[]; NSMutableArray *selected = [NSMutableArray array]; NSString *currentUid = book.currentChapterUid; NSInteger currentIndex = 0; // Find current chapter index for (NSUInteger i = 0; i < chapters.count; i++) { NSDictionary *chapter = chapters[i]; if ([chapter[@"chapterUid"] isEqualToString:currentUid]) { currentIndex = (NSInteger)i; break; } } // Select chapters based on scene switch (scene) { case WRPreloadSceneReading: // Preload next chapters from current position for (NSInteger i = currentIndex; i < MIN(currentIndex + (NSInteger)maxCount, (NSInteger)chapters.count); i++) { NSDictionary *chapter = chapters[i]; NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"]; if (uid) [selected addObject:uid]; } break; case WRPreloadSceneShelf: // Preload from the beginning (user might start reading) for (NSUInteger i = 0; i < MIN(maxCount, chapters.count); i++) { NSDictionary *chapter = chapters[i]; NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"]; if (uid) [selected addObject:uid]; } break; case WRPreloadSceneWiFi: // Aggressive: preload all chapters for (NSDictionary *chapter in chapters) { NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"]; if (uid) [selected addObject:uid]; if (selected.count >= maxCount) break; } break; default: // Minimal: just the next chapter if (currentIndex + 1 < (NSInteger)chapters.count) { NSDictionary *chapter = chapters[currentIndex + 1]; NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"]; if (uid) [selected addObject:uid]; } break; } return [selected copy]; } - (BOOL)_isOnWiFi { // Check network status // In the actual app, this uses SCNetworkReachability or a wrapper // For reconstruction, assume WiFi if not explicitly cellular NSNumber *isWiFi = [[NSUserDefaults standardUserDefaults] objectForKey:@"WRIsOnWiFi"]; return isWiFi ? isWiFi.boolValue : YES; // Default to WiFi for safety } - (void)_networkStatusChanged:(NSNotification *)notification { // Adjust preload behavior when network changes BOOL isWiFi = [notification.userInfo[@"isWiFi"] boolValue]; if (!isWiFi) { // On cellular: cancel aggressive preloads for (NSString *bookId in [_preloadState copy]) { NSDictionary *state = _preloadState[bookId]; if ([state[@"scene"] integerValue] == WRPreloadSceneWiFi) { [self cancelPreloadForBookId:bookId]; } } } } - (NSString *)_preloadCacheDirectory { NSString *caches = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES).firstObject; return [caches stringByAppendingPathComponent:kPreloadCacheDir]; } - (NSString *)_plainBookDirectoryForBookId:(NSString *)bookId { NSString *caches = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES).firstObject; return [[caches stringByAppendingPathComponent:@"plain_books"] stringByAppendingPathComponent:bookId]; } - (NSUInteger)_directorySizeAtPath:(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; } @end