548 lines
18 KiB
Objective-C
548 lines
18 KiB
Objective-C
//
|
|
// 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
|