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