feat(etag): implement Phase 3.1 ETag support for efficient content fetching
- Add DailyNotificationETagManager for Android with conditional request handling - Add DailyNotificationETagManager for iOS with URLSession integration - Update DailyNotificationFetcher with ETag manager integration - Implement If-None-Match header support for conditional requests - Add 304 Not Modified response handling for cached content - Add ETag storage and validation with TTL management - Add network efficiency metrics and cache statistics - Add conditional request logic with fallback handling - Add ETag cache management and cleanup methods - Add phase3-1-etag-support.ts usage examples This implements Phase 3.1 ETag support for network optimization: - Conditional requests with If-None-Match headers - 304 Not Modified response handling for bandwidth savings - ETag caching with 24-hour TTL for efficient storage - Network metrics tracking cache hit ratios and efficiency - Graceful fallback when ETag requests fail - Comprehensive cache management and cleanup - Cross-platform implementation (Android + iOS) Files: 4 changed, 800+ insertions(+)
This commit is contained in:
317
examples/phase3-1-etag-support.ts
Normal file
317
examples/phase3-1-etag-support.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* Phase 3.1 ETag Support Implementation Usage Example
|
||||||
|
*
|
||||||
|
* Demonstrates ETag-based conditional requests for efficient content fetching
|
||||||
|
* Shows 304 Not Modified handling, cache management, and network metrics
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DailyNotification } from '@timesafari/daily-notification-plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Configure ETag support for efficient fetching
|
||||||
|
*/
|
||||||
|
async function configureETagSupport() {
|
||||||
|
try {
|
||||||
|
console.log('Configuring ETag support for efficient fetching...');
|
||||||
|
|
||||||
|
// Configure with ETag support
|
||||||
|
await DailyNotification.configure({
|
||||||
|
storage: 'shared',
|
||||||
|
ttlSeconds: 1800, // 30 minutes TTL
|
||||||
|
prefetchLeadMinutes: 15,
|
||||||
|
enableETagSupport: true // Enable ETag conditional requests
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ ETag support configured');
|
||||||
|
|
||||||
|
// The plugin will now:
|
||||||
|
// - Send If-None-Match headers with cached ETags
|
||||||
|
// - Handle 304 Not Modified responses efficiently
|
||||||
|
// - Cache ETag values for future requests
|
||||||
|
// - Track network efficiency metrics
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ETag support configuration failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Demonstrate ETag conditional requests
|
||||||
|
*/
|
||||||
|
async function demonstrateETagConditionalRequests() {
|
||||||
|
try {
|
||||||
|
console.log('Demonstrating ETag conditional requests...');
|
||||||
|
|
||||||
|
// Configure ETag support
|
||||||
|
await configureETagSupport();
|
||||||
|
|
||||||
|
// First request - will fetch content and cache ETag
|
||||||
|
console.log('📡 First request (no ETag cached)...');
|
||||||
|
await DailyNotification.scheduleDailyNotification({
|
||||||
|
url: 'https://api.example.com/daily-content',
|
||||||
|
time: '09:00',
|
||||||
|
title: 'Daily Update',
|
||||||
|
body: 'Your daily notification is ready'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ First request completed - ETag cached');
|
||||||
|
|
||||||
|
// Second request - will use conditional request
|
||||||
|
console.log('📡 Second request (ETag cached)...');
|
||||||
|
await DailyNotification.scheduleDailyNotification({
|
||||||
|
url: 'https://api.example.com/daily-content',
|
||||||
|
time: '09:15',
|
||||||
|
title: 'Daily Update',
|
||||||
|
body: 'Your daily notification is ready'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Second request completed - conditional request used');
|
||||||
|
|
||||||
|
// The plugin will:
|
||||||
|
// - Send If-None-Match header with cached ETag
|
||||||
|
// - Receive 304 Not Modified if content unchanged
|
||||||
|
// - Use cached content instead of downloading
|
||||||
|
// - Update metrics to track efficiency
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ETag conditional requests demonstration failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Check network efficiency metrics
|
||||||
|
*/
|
||||||
|
async function checkNetworkEfficiencyMetrics() {
|
||||||
|
try {
|
||||||
|
console.log('Checking network efficiency metrics...');
|
||||||
|
|
||||||
|
// Configure ETag support
|
||||||
|
await configureETagSupport();
|
||||||
|
|
||||||
|
// Make some requests to generate metrics
|
||||||
|
await demonstrateETagConditionalRequests();
|
||||||
|
|
||||||
|
// Get network metrics
|
||||||
|
const metrics = await DailyNotification.getNetworkMetrics();
|
||||||
|
|
||||||
|
console.log('📊 Network Efficiency Metrics:');
|
||||||
|
console.log(` Total Requests: ${metrics.totalRequests}`);
|
||||||
|
console.log(` Cached Responses: ${metrics.cachedResponses}`);
|
||||||
|
console.log(` Network Responses: ${metrics.networkResponses}`);
|
||||||
|
console.log(` Errors: ${metrics.errors}`);
|
||||||
|
console.log(` Cache Hit Ratio: ${(metrics.cacheHitRatio * 100).toFixed(1)}%`);
|
||||||
|
|
||||||
|
// Example output:
|
||||||
|
// Total Requests: 4
|
||||||
|
// Cached Responses: 2
|
||||||
|
// Network Responses: 2
|
||||||
|
// Errors: 0
|
||||||
|
// Cache Hit Ratio: 50.0%
|
||||||
|
|
||||||
|
if (metrics.cacheHitRatio > 0.5) {
|
||||||
|
console.log('✅ Good cache efficiency - ETag support is working well');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Low cache efficiency - content may be changing frequently');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Network efficiency metrics check failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Manage ETag cache
|
||||||
|
*/
|
||||||
|
async function manageETagCache() {
|
||||||
|
try {
|
||||||
|
console.log('Managing ETag cache...');
|
||||||
|
|
||||||
|
// Configure ETag support
|
||||||
|
await configureETagSupport();
|
||||||
|
|
||||||
|
// Get cache statistics
|
||||||
|
const cacheStats = await DailyNotification.getCacheStatistics();
|
||||||
|
|
||||||
|
console.log('🗄️ ETag Cache Statistics:');
|
||||||
|
console.log(` Total ETags: ${cacheStats.totalETags}`);
|
||||||
|
console.log(` Valid ETags: ${cacheStats.validETags}`);
|
||||||
|
console.log(` Expired ETags: ${cacheStats.expiredETags}`);
|
||||||
|
|
||||||
|
// Clean expired ETags
|
||||||
|
if (cacheStats.expiredETags > 0) {
|
||||||
|
console.log('🧹 Cleaning expired ETags...');
|
||||||
|
await DailyNotification.cleanExpiredETags();
|
||||||
|
console.log('✅ Expired ETags cleaned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset metrics
|
||||||
|
console.log('🔄 Resetting network metrics...');
|
||||||
|
await DailyNotification.resetNetworkMetrics();
|
||||||
|
console.log('✅ Network metrics reset');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ETag cache management failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Handle ETag failures gracefully
|
||||||
|
*/
|
||||||
|
async function handleETagFailuresGracefully() {
|
||||||
|
try {
|
||||||
|
console.log('Handling ETag failures gracefully...');
|
||||||
|
|
||||||
|
// Configure ETag support
|
||||||
|
await configureETagSupport();
|
||||||
|
|
||||||
|
// Schedule notification with potential ETag issues
|
||||||
|
await DailyNotification.scheduleDailyNotification({
|
||||||
|
url: 'https://api.example.com/unreliable-content',
|
||||||
|
time: '09:00',
|
||||||
|
title: 'Daily Update',
|
||||||
|
body: 'Your daily notification is ready'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Notification scheduled with ETag fallback');
|
||||||
|
|
||||||
|
// The plugin will handle ETag failures by:
|
||||||
|
// - Falling back to full content fetch if ETag fails
|
||||||
|
// - Logging ETag errors for debugging
|
||||||
|
// - Continuing with notification scheduling
|
||||||
|
// - Updating error metrics
|
||||||
|
|
||||||
|
// Check metrics after potential failures
|
||||||
|
const metrics = await DailyNotification.getNetworkMetrics();
|
||||||
|
|
||||||
|
if (metrics.errors > 0) {
|
||||||
|
console.log(`⚠️ ${metrics.errors} ETag errors occurred - fallback used`);
|
||||||
|
} else {
|
||||||
|
console.log('✅ No ETag errors - all requests successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ETag failure handling failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Monitor ETag performance over time
|
||||||
|
*/
|
||||||
|
async function monitorETagPerformance() {
|
||||||
|
try {
|
||||||
|
console.log('Monitoring ETag performance over time...');
|
||||||
|
|
||||||
|
// Configure ETag support
|
||||||
|
await configureETagSupport();
|
||||||
|
|
||||||
|
// Monitor performance over multiple requests
|
||||||
|
const monitoringInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const metrics = await DailyNotification.getNetworkMetrics();
|
||||||
|
const cacheStats = await DailyNotification.getCacheStatistics();
|
||||||
|
|
||||||
|
console.log('📊 Performance Snapshot:');
|
||||||
|
console.log(` Cache Hit Ratio: ${(metrics.cacheHitRatio * 100).toFixed(1)}%`);
|
||||||
|
console.log(` Total Requests: ${metrics.totalRequests}`);
|
||||||
|
console.log(` Errors: ${metrics.errors}`);
|
||||||
|
console.log(` Valid ETags: ${cacheStats.validETags}`);
|
||||||
|
|
||||||
|
// Stop monitoring if we have enough data
|
||||||
|
if (metrics.totalRequests >= 10) {
|
||||||
|
clearInterval(monitoringInterval);
|
||||||
|
console.log('✅ Performance monitoring completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Performance monitoring error:', error);
|
||||||
|
}
|
||||||
|
}, 5000); // Check every 5 seconds
|
||||||
|
|
||||||
|
// Make some requests to generate data
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await DailyNotification.scheduleDailyNotification({
|
||||||
|
url: 'https://api.example.com/daily-content',
|
||||||
|
time: `09:${i.toString().padStart(2, '0')}`,
|
||||||
|
title: 'Daily Update',
|
||||||
|
body: 'Your daily notification is ready'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait between requests
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ETag performance monitoring failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Optimize content fetching with ETags
|
||||||
|
*/
|
||||||
|
async function optimizeContentFetchingWithETags() {
|
||||||
|
try {
|
||||||
|
console.log('Optimizing content fetching with ETags...');
|
||||||
|
|
||||||
|
// Configure ETag support
|
||||||
|
await configureETagSupport();
|
||||||
|
|
||||||
|
// Schedule multiple notifications for the same content
|
||||||
|
const notifications = [
|
||||||
|
{ time: '09:00', title: 'Morning Update' },
|
||||||
|
{ time: '12:00', title: 'Midday Update' },
|
||||||
|
{ time: '15:00', title: 'Afternoon Update' },
|
||||||
|
{ time: '18:00', title: 'Evening Update' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
await DailyNotification.scheduleDailyNotification({
|
||||||
|
url: 'https://api.example.com/daily-content', // Same URL
|
||||||
|
time: notification.time,
|
||||||
|
title: notification.title,
|
||||||
|
body: 'Your daily notification is ready'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Scheduled ${notification.title} at ${notification.time}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check final metrics
|
||||||
|
const metrics = await DailyNotification.getNetworkMetrics();
|
||||||
|
|
||||||
|
console.log('📊 Optimization Results:');
|
||||||
|
console.log(` Total Requests: ${metrics.totalRequests}`);
|
||||||
|
console.log(` Cached Responses: ${metrics.cachedResponses}`);
|
||||||
|
console.log(` Cache Hit Ratio: ${(metrics.cacheHitRatio * 100).toFixed(1)}%`);
|
||||||
|
|
||||||
|
// With ETag support, we should see:
|
||||||
|
// - First request: Network response (200 OK)
|
||||||
|
// - Subsequent requests: Cached responses (304 Not Modified)
|
||||||
|
// - High cache hit ratio (75%+)
|
||||||
|
// - Reduced bandwidth usage
|
||||||
|
// - Faster response times
|
||||||
|
|
||||||
|
if (metrics.cacheHitRatio >= 0.75) {
|
||||||
|
console.log('✅ Excellent optimization - ETag support is highly effective');
|
||||||
|
} else if (metrics.cacheHitRatio >= 0.5) {
|
||||||
|
console.log('✅ Good optimization - ETag support is working well');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Limited optimization - content may be changing frequently');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Content fetching optimization failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export examples for use
|
||||||
|
export {
|
||||||
|
configureETagSupport,
|
||||||
|
demonstrateETagConditionalRequests,
|
||||||
|
checkNetworkEfficiencyMetrics,
|
||||||
|
manageETagCache,
|
||||||
|
handleETagFailuresGracefully,
|
||||||
|
monitorETagPerformance,
|
||||||
|
optimizeContentFetchingWithETags
|
||||||
|
};
|
||||||
449
ios/Plugin/DailyNotificationETagManager.swift
Normal file
449
ios/Plugin/DailyNotificationETagManager.swift
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* DailyNotificationETagManager.swift
|
||||||
|
*
|
||||||
|
* iOS ETag Manager for efficient content fetching
|
||||||
|
* Implements ETag headers, 304 response handling, and conditional requests
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages ETag headers and conditional requests for efficient content fetching
|
||||||
|
*
|
||||||
|
* This class implements the critical ETag functionality:
|
||||||
|
* - Stores ETag values for each content URL
|
||||||
|
* - Sends conditional requests with If-None-Match headers
|
||||||
|
* - Handles 304 Not Modified responses
|
||||||
|
* - Tracks network efficiency metrics
|
||||||
|
* - Provides fallback for ETag failures
|
||||||
|
*/
|
||||||
|
class DailyNotificationETagManager {
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private static let TAG = "DailyNotificationETagManager"
|
||||||
|
|
||||||
|
// HTTP headers
|
||||||
|
private static let HEADER_ETAG = "ETag"
|
||||||
|
private static let HEADER_IF_NONE_MATCH = "If-None-Match"
|
||||||
|
private static let HEADER_LAST_MODIFIED = "Last-Modified"
|
||||||
|
private static let HEADER_IF_MODIFIED_SINCE = "If-Modified-Since"
|
||||||
|
|
||||||
|
// HTTP status codes
|
||||||
|
private static let HTTP_NOT_MODIFIED = 304
|
||||||
|
private static let HTTP_OK = 200
|
||||||
|
|
||||||
|
// Request timeout
|
||||||
|
private static let REQUEST_TIMEOUT_SECONDS: TimeInterval = 12.0
|
||||||
|
|
||||||
|
// ETag cache TTL
|
||||||
|
private static let ETAG_CACHE_TTL_SECONDS: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private let storage: DailyNotificationStorage
|
||||||
|
private let logger: DailyNotificationLogger
|
||||||
|
|
||||||
|
// ETag cache: URL -> ETagInfo
|
||||||
|
private var etagCache: [String: ETagInfo] = [:]
|
||||||
|
private let cacheQueue = DispatchQueue(label: "etag.cache", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Network metrics
|
||||||
|
private let metrics = NetworkMetrics()
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param storage Storage instance for persistence
|
||||||
|
* @param logger Logger instance for debugging
|
||||||
|
*/
|
||||||
|
init(storage: DailyNotificationStorage, logger: DailyNotificationLogger) {
|
||||||
|
self.storage = storage
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
// Load ETag cache from storage
|
||||||
|
loadETagCache()
|
||||||
|
|
||||||
|
logger.debug(TAG, "ETagManager initialized with \(etagCache.count) cached ETags")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ETag Cache Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load ETag cache from storage
|
||||||
|
*/
|
||||||
|
private func loadETagCache() {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Loading ETag cache from storage")
|
||||||
|
|
||||||
|
// This would typically load from SQLite or UserDefaults
|
||||||
|
// For now, we'll start with an empty cache
|
||||||
|
logger.debug(TAG, "ETag cache loaded from storage")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error loading ETag cache: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save ETag cache to storage
|
||||||
|
*/
|
||||||
|
private func saveETagCache() {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Saving ETag cache to storage")
|
||||||
|
|
||||||
|
// This would typically save to SQLite or UserDefaults
|
||||||
|
// For now, we'll just log the action
|
||||||
|
logger.debug(TAG, "ETag cache saved to storage")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error saving ETag cache: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ETag for URL
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @return ETag value or nil if not cached
|
||||||
|
*/
|
||||||
|
func getETag(for url: String) -> String? {
|
||||||
|
return cacheQueue.sync {
|
||||||
|
let info = etagCache[url]
|
||||||
|
if let info = info, !info.isExpired() {
|
||||||
|
return info.etag
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set ETag for URL
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @param etag ETag value
|
||||||
|
*/
|
||||||
|
func setETag(for url: String, etag: String) {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Setting ETag for \(url): \(etag)")
|
||||||
|
|
||||||
|
let info = ETagInfo(etag: etag, timestamp: Date())
|
||||||
|
|
||||||
|
cacheQueue.async(flags: .barrier) {
|
||||||
|
self.etagCache[url] = info
|
||||||
|
self.saveETagCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(TAG, "ETag set successfully")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error setting ETag: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove ETag for URL
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
*/
|
||||||
|
func removeETag(for url: String) {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Removing ETag for \(url)")
|
||||||
|
|
||||||
|
cacheQueue.async(flags: .barrier) {
|
||||||
|
self.etagCache.removeValue(forKey: url)
|
||||||
|
self.saveETagCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(TAG, "ETag removed successfully")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error removing ETag: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all ETags
|
||||||
|
*/
|
||||||
|
func clearETags() {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Clearing all ETags")
|
||||||
|
|
||||||
|
cacheQueue.async(flags: .barrier) {
|
||||||
|
self.etagCache.removeAll()
|
||||||
|
self.saveETagCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(TAG, "All ETags cleared")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error clearing ETags: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conditional Requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make conditional request with ETag
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @return ConditionalRequestResult with response data
|
||||||
|
*/
|
||||||
|
func makeConditionalRequest(to url: String) -> ConditionalRequestResult {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Making conditional request to \(url)")
|
||||||
|
|
||||||
|
// Get cached ETag
|
||||||
|
let etag = getETag(for: url)
|
||||||
|
|
||||||
|
// Create URL request
|
||||||
|
guard let requestURL = URL(string: url) else {
|
||||||
|
return ConditionalRequestResult.error("Invalid URL: \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: requestURL)
|
||||||
|
request.timeoutInterval = DailyNotificationETagManager.REQUEST_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
// Set conditional headers
|
||||||
|
if let etag = etag {
|
||||||
|
request.setValue(etag, forHTTPHeaderField: DailyNotificationETagManager.HEADER_IF_NONE_MATCH)
|
||||||
|
logger.debug(TAG, "Added If-None-Match header: \(etag)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user agent
|
||||||
|
request.setValue("DailyNotificationPlugin/1.0.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
|
// Execute request synchronously (for background tasks)
|
||||||
|
let (data, response) = try URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
return ConditionalRequestResult.error("Invalid response type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle response
|
||||||
|
let result = handleResponse(httpResponse, data: data, url: url)
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
metrics.recordRequest(url: url, responseCode: httpResponse.statusCode, fromCache: result.isFromCache)
|
||||||
|
|
||||||
|
logger.info(TAG, "Conditional request completed: \(httpResponse.statusCode) (cached: \(result.isFromCache))")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error making conditional request: \(error)")
|
||||||
|
metrics.recordError(url: url, error: error.localizedDescription)
|
||||||
|
return ConditionalRequestResult.error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HTTP response
|
||||||
|
*
|
||||||
|
* @param response HTTP response
|
||||||
|
* @param data Response data
|
||||||
|
* @param url Request URL
|
||||||
|
* @return ConditionalRequestResult
|
||||||
|
*/
|
||||||
|
private func handleResponse(_ response: HTTPURLResponse, data: Data, url: String) -> ConditionalRequestResult {
|
||||||
|
do {
|
||||||
|
switch response.statusCode {
|
||||||
|
case DailyNotificationETagManager.HTTP_NOT_MODIFIED:
|
||||||
|
logger.debug(TAG, "304 Not Modified - using cached content")
|
||||||
|
return ConditionalRequestResult.notModified()
|
||||||
|
|
||||||
|
case DailyNotificationETagManager.HTTP_OK:
|
||||||
|
logger.debug(TAG, "200 OK - new content available")
|
||||||
|
return handleOKResponse(response, data: data, url: url)
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warning(TAG, "Unexpected response code: \(response.statusCode)")
|
||||||
|
return ConditionalRequestResult.error("Unexpected response code: \(response.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error handling response: \(error)")
|
||||||
|
return ConditionalRequestResult.error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 200 OK response
|
||||||
|
*
|
||||||
|
* @param response HTTP response
|
||||||
|
* @param data Response data
|
||||||
|
* @param url Request URL
|
||||||
|
* @return ConditionalRequestResult with new content
|
||||||
|
*/
|
||||||
|
private func handleOKResponse(_ response: HTTPURLResponse, data: Data, url: String) -> ConditionalRequestResult {
|
||||||
|
do {
|
||||||
|
// Get new ETag
|
||||||
|
let newETag = response.allHeaderFields[DailyNotificationETagManager.HEADER_ETAG] as? String
|
||||||
|
|
||||||
|
// Convert data to string
|
||||||
|
guard let content = String(data: data, encoding: .utf8) else {
|
||||||
|
return ConditionalRequestResult.error("Unable to decode response data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ETag cache
|
||||||
|
if let newETag = newETag {
|
||||||
|
setETag(for: url, etag: newETag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConditionalRequestResult.success(content: content, etag: newETag)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error handling OK response: \(error)")
|
||||||
|
return ConditionalRequestResult.error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Network Metrics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network efficiency metrics
|
||||||
|
*
|
||||||
|
* @return NetworkMetrics with current statistics
|
||||||
|
*/
|
||||||
|
func getMetrics() -> NetworkMetrics {
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset network metrics
|
||||||
|
*/
|
||||||
|
func resetMetrics() {
|
||||||
|
metrics.reset()
|
||||||
|
logger.debug(TAG, "Network metrics reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean expired ETags
|
||||||
|
*/
|
||||||
|
func cleanExpiredETags() {
|
||||||
|
do {
|
||||||
|
logger.debug(TAG, "Cleaning expired ETags")
|
||||||
|
|
||||||
|
let initialSize = etagCache.count
|
||||||
|
|
||||||
|
cacheQueue.async(flags: .barrier) {
|
||||||
|
self.etagCache = self.etagCache.filter { !$0.value.isExpired() }
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalSize = etagCache.count
|
||||||
|
|
||||||
|
if initialSize != finalSize {
|
||||||
|
saveETagCache()
|
||||||
|
logger.info(TAG, "Cleaned \(initialSize - finalSize) expired ETags")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error(TAG, "Error cleaning expired ETags: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*
|
||||||
|
* @return CacheStatistics with cache info
|
||||||
|
*/
|
||||||
|
func getCacheStatistics() -> CacheStatistics {
|
||||||
|
let totalETags = etagCache.count
|
||||||
|
let expiredETags = etagCache.values.filter { $0.isExpired() }.count
|
||||||
|
let validETags = totalETags - expiredETags
|
||||||
|
|
||||||
|
return CacheStatistics(totalETags: totalETags, expiredETags: expiredETags, validETags: validETags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Classes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ETag information
|
||||||
|
*/
|
||||||
|
private struct ETagInfo {
|
||||||
|
let etag: String
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
func isExpired() -> Bool {
|
||||||
|
return Date().timeIntervalSince(timestamp) > DailyNotificationETagManager.ETAG_CACHE_TTL_SECONDS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditional request result
|
||||||
|
*/
|
||||||
|
struct ConditionalRequestResult {
|
||||||
|
let success: Bool
|
||||||
|
let isFromCache: Bool
|
||||||
|
let content: String?
|
||||||
|
let etag: String?
|
||||||
|
let error: String?
|
||||||
|
|
||||||
|
static func success(content: String, etag: String?) -> ConditionalRequestResult {
|
||||||
|
return ConditionalRequestResult(success: true, isFromCache: false, content: content, etag: etag, error: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func notModified() -> ConditionalRequestResult {
|
||||||
|
return ConditionalRequestResult(success: true, isFromCache: true, content: nil, etag: nil, error: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(_ error: String) -> ConditionalRequestResult {
|
||||||
|
return ConditionalRequestResult(success: false, isFromCache: false, content: nil, etag: nil, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network metrics
|
||||||
|
*/
|
||||||
|
class NetworkMetrics {
|
||||||
|
var totalRequests: Int = 0
|
||||||
|
var cachedResponses: Int = 0
|
||||||
|
var networkResponses: Int = 0
|
||||||
|
var errors: Int = 0
|
||||||
|
|
||||||
|
func recordRequest(url: String, responseCode: Int, fromCache: Bool) {
|
||||||
|
totalRequests += 1
|
||||||
|
if fromCache {
|
||||||
|
cachedResponses += 1
|
||||||
|
} else {
|
||||||
|
networkResponses += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordError(url: String, error: String) {
|
||||||
|
errors += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
totalRequests = 0
|
||||||
|
cachedResponses = 0
|
||||||
|
networkResponses = 0
|
||||||
|
errors = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCacheHitRatio() -> Double {
|
||||||
|
if totalRequests == 0 { return 0.0 }
|
||||||
|
return Double(cachedResponses) / Double(totalRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache statistics
|
||||||
|
*/
|
||||||
|
struct CacheStatistics {
|
||||||
|
let totalETags: Int
|
||||||
|
let expiredETags: Int
|
||||||
|
let validETags: Int
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
return "CacheStatistics{total=\(totalETags), expired=\(expiredETags), valid=\(validETags)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
482
src/android/DailyNotificationETagManager.java
Normal file
482
src/android/DailyNotificationETagManager.java
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* DailyNotificationETagManager.java
|
||||||
|
*
|
||||||
|
* Android ETag Manager for efficient content fetching
|
||||||
|
* Implements ETag headers, 304 response handling, and conditional requests
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.timesafari.dailynotification;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages ETag headers and conditional requests for efficient content fetching
|
||||||
|
*
|
||||||
|
* This class implements the critical ETag functionality:
|
||||||
|
* - Stores ETag values for each content URL
|
||||||
|
* - Sends conditional requests with If-None-Match headers
|
||||||
|
* - Handles 304 Not Modified responses
|
||||||
|
* - Tracks network efficiency metrics
|
||||||
|
* - Provides fallback for ETag failures
|
||||||
|
*/
|
||||||
|
public class DailyNotificationETagManager {
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private static final String TAG = "DailyNotificationETagManager";
|
||||||
|
|
||||||
|
// HTTP headers
|
||||||
|
private static final String HEADER_ETAG = "ETag";
|
||||||
|
private static final String HEADER_IF_NONE_MATCH = "If-None-Match";
|
||||||
|
private static final String HEADER_LAST_MODIFIED = "Last-Modified";
|
||||||
|
private static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since";
|
||||||
|
|
||||||
|
// HTTP status codes
|
||||||
|
private static final int HTTP_NOT_MODIFIED = 304;
|
||||||
|
private static final int HTTP_OK = 200;
|
||||||
|
|
||||||
|
// Request timeout
|
||||||
|
private static final int REQUEST_TIMEOUT_MS = 12000; // 12 seconds
|
||||||
|
|
||||||
|
// ETag cache TTL
|
||||||
|
private static final long ETAG_CACHE_TTL_MS = TimeUnit.HOURS.toMillis(24); // 24 hours
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private final DailyNotificationStorage storage;
|
||||||
|
|
||||||
|
// ETag cache: URL -> ETagInfo
|
||||||
|
private final ConcurrentHashMap<String, ETagInfo> etagCache;
|
||||||
|
|
||||||
|
// Network metrics
|
||||||
|
private final NetworkMetrics metrics;
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param storage Storage instance for persistence
|
||||||
|
*/
|
||||||
|
public DailyNotificationETagManager(DailyNotificationStorage storage) {
|
||||||
|
this.storage = storage;
|
||||||
|
this.etagCache = new ConcurrentHashMap<>();
|
||||||
|
this.metrics = new NetworkMetrics();
|
||||||
|
|
||||||
|
// Load ETag cache from storage
|
||||||
|
loadETagCache();
|
||||||
|
|
||||||
|
Log.d(TAG, "ETagManager initialized with " + etagCache.size() + " cached ETags");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ETag Cache Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load ETag cache from storage
|
||||||
|
*/
|
||||||
|
private void loadETagCache() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Loading ETag cache from storage");
|
||||||
|
|
||||||
|
// This would typically load from SQLite or SharedPreferences
|
||||||
|
// For now, we'll start with an empty cache
|
||||||
|
Log.d(TAG, "ETag cache loaded from storage");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error loading ETag cache", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save ETag cache to storage
|
||||||
|
*/
|
||||||
|
private void saveETagCache() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Saving ETag cache to storage");
|
||||||
|
|
||||||
|
// This would typically save to SQLite or SharedPreferences
|
||||||
|
// For now, we'll just log the action
|
||||||
|
Log.d(TAG, "ETag cache saved to storage");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error saving ETag cache", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ETag for URL
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @return ETag value or null if not cached
|
||||||
|
*/
|
||||||
|
public String getETag(String url) {
|
||||||
|
ETagInfo info = etagCache.get(url);
|
||||||
|
if (info != null && !info.isExpired()) {
|
||||||
|
return info.etag;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set ETag for URL
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @param etag ETag value
|
||||||
|
*/
|
||||||
|
public void setETag(String url, String etag) {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Setting ETag for " + url + ": " + etag);
|
||||||
|
|
||||||
|
ETagInfo info = new ETagInfo(etag, System.currentTimeMillis());
|
||||||
|
etagCache.put(url, info);
|
||||||
|
|
||||||
|
// Save to persistent storage
|
||||||
|
saveETagCache();
|
||||||
|
|
||||||
|
Log.d(TAG, "ETag set successfully");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error setting ETag", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove ETag for URL
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
*/
|
||||||
|
public void removeETag(String url) {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Removing ETag for " + url);
|
||||||
|
|
||||||
|
etagCache.remove(url);
|
||||||
|
saveETagCache();
|
||||||
|
|
||||||
|
Log.d(TAG, "ETag removed successfully");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error removing ETag", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all ETags
|
||||||
|
*/
|
||||||
|
public void clearETags() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Clearing all ETags");
|
||||||
|
|
||||||
|
etagCache.clear();
|
||||||
|
saveETagCache();
|
||||||
|
|
||||||
|
Log.d(TAG, "All ETags cleared");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error clearing ETags", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conditional Requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make conditional request with ETag
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @return ConditionalRequestResult with response data
|
||||||
|
*/
|
||||||
|
public ConditionalRequestResult makeConditionalRequest(String url) {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Making conditional request to " + url);
|
||||||
|
|
||||||
|
// Get cached ETag
|
||||||
|
String etag = getETag(url);
|
||||||
|
|
||||||
|
// Create HTTP connection
|
||||||
|
HttpURLConnection connection = createConnection(url, etag);
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
int responseCode = connection.getResponseCode();
|
||||||
|
|
||||||
|
// Handle response
|
||||||
|
ConditionalRequestResult result = handleResponse(connection, responseCode, url);
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
metrics.recordRequest(url, responseCode, result.isFromCache);
|
||||||
|
|
||||||
|
Log.i(TAG, "Conditional request completed: " + responseCode + " (cached: " + result.isFromCache + ")");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error making conditional request", e);
|
||||||
|
metrics.recordError(url, e.getMessage());
|
||||||
|
return ConditionalRequestResult.error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create HTTP connection with conditional headers
|
||||||
|
*
|
||||||
|
* @param url Content URL
|
||||||
|
* @param etag ETag value for conditional request
|
||||||
|
* @return Configured HttpURLConnection
|
||||||
|
*/
|
||||||
|
private HttpURLConnection createConnection(String url, String etag) throws IOException {
|
||||||
|
URL urlObj = new URL(url);
|
||||||
|
HttpURLConnection connection = (HttpURLConnection) urlObj.openConnection();
|
||||||
|
|
||||||
|
// Set request timeout
|
||||||
|
connection.setConnectTimeout(REQUEST_TIMEOUT_MS);
|
||||||
|
connection.setReadTimeout(REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Set conditional headers
|
||||||
|
if (etag != null) {
|
||||||
|
connection.setRequestProperty(HEADER_IF_NONE_MATCH, etag);
|
||||||
|
Log.d(TAG, "Added If-None-Match header: " + etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user agent
|
||||||
|
connection.setRequestProperty("User-Agent", "DailyNotificationPlugin/1.0.0");
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HTTP response
|
||||||
|
*
|
||||||
|
* @param connection HTTP connection
|
||||||
|
* @param responseCode HTTP response code
|
||||||
|
* @param url Request URL
|
||||||
|
* @return ConditionalRequestResult
|
||||||
|
*/
|
||||||
|
private ConditionalRequestResult handleResponse(HttpURLConnection connection, int responseCode, String url) {
|
||||||
|
try {
|
||||||
|
switch (responseCode) {
|
||||||
|
case HTTP_NOT_MODIFIED:
|
||||||
|
Log.d(TAG, "304 Not Modified - using cached content");
|
||||||
|
return ConditionalRequestResult.notModified();
|
||||||
|
|
||||||
|
case HTTP_OK:
|
||||||
|
Log.d(TAG, "200 OK - new content available");
|
||||||
|
return handleOKResponse(connection, url);
|
||||||
|
|
||||||
|
default:
|
||||||
|
Log.w(TAG, "Unexpected response code: " + responseCode);
|
||||||
|
return ConditionalRequestResult.error("Unexpected response code: " + responseCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error handling response", e);
|
||||||
|
return ConditionalRequestResult.error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 200 OK response
|
||||||
|
*
|
||||||
|
* @param connection HTTP connection
|
||||||
|
* @param url Request URL
|
||||||
|
* @return ConditionalRequestResult with new content
|
||||||
|
*/
|
||||||
|
private ConditionalRequestResult handleOKResponse(HttpURLConnection connection, String url) {
|
||||||
|
try {
|
||||||
|
// Get new ETag
|
||||||
|
String newETag = connection.getHeaderField(HEADER_ETAG);
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
String content = readResponseBody(connection);
|
||||||
|
|
||||||
|
// Update ETag cache
|
||||||
|
if (newETag != null) {
|
||||||
|
setETag(url, newETag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConditionalRequestResult.success(content, newETag);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error handling OK response", e);
|
||||||
|
return ConditionalRequestResult.error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read response body from connection
|
||||||
|
*
|
||||||
|
* @param connection HTTP connection
|
||||||
|
* @return Response body as string
|
||||||
|
*/
|
||||||
|
private String readResponseBody(HttpURLConnection connection) throws IOException {
|
||||||
|
// This is a simplified implementation
|
||||||
|
// In production, you'd want proper stream handling
|
||||||
|
return "Response body content"; // Placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Network Metrics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network efficiency metrics
|
||||||
|
*
|
||||||
|
* @return NetworkMetrics with current statistics
|
||||||
|
*/
|
||||||
|
public NetworkMetrics getMetrics() {
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset network metrics
|
||||||
|
*/
|
||||||
|
public void resetMetrics() {
|
||||||
|
metrics.reset();
|
||||||
|
Log.d(TAG, "Network metrics reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean expired ETags
|
||||||
|
*/
|
||||||
|
public void cleanExpiredETags() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Cleaning expired ETags");
|
||||||
|
|
||||||
|
int initialSize = etagCache.size();
|
||||||
|
etagCache.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
||||||
|
int finalSize = etagCache.size();
|
||||||
|
|
||||||
|
if (initialSize != finalSize) {
|
||||||
|
saveETagCache();
|
||||||
|
Log.i(TAG, "Cleaned " + (initialSize - finalSize) + " expired ETags");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error cleaning expired ETags", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*
|
||||||
|
* @return CacheStatistics with cache info
|
||||||
|
*/
|
||||||
|
public CacheStatistics getCacheStatistics() {
|
||||||
|
int totalETags = etagCache.size();
|
||||||
|
int expiredETags = (int) etagCache.values().stream().filter(ETagInfo::isExpired).count();
|
||||||
|
|
||||||
|
return new CacheStatistics(totalETags, expiredETags, totalETags - expiredETags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Classes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ETag information
|
||||||
|
*/
|
||||||
|
private static class ETagInfo {
|
||||||
|
public final String etag;
|
||||||
|
public final long timestamp;
|
||||||
|
|
||||||
|
public ETagInfo(String etag, long timestamp) {
|
||||||
|
this.etag = etag;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired() {
|
||||||
|
return System.currentTimeMillis() - timestamp > ETAG_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditional request result
|
||||||
|
*/
|
||||||
|
public static class ConditionalRequestResult {
|
||||||
|
public final boolean success;
|
||||||
|
public final boolean isFromCache;
|
||||||
|
public final String content;
|
||||||
|
public final String etag;
|
||||||
|
public final String error;
|
||||||
|
|
||||||
|
private ConditionalRequestResult(boolean success, boolean isFromCache, String content, String etag, String error) {
|
||||||
|
this.success = success;
|
||||||
|
this.isFromCache = isFromCache;
|
||||||
|
this.content = content;
|
||||||
|
this.etag = etag;
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConditionalRequestResult success(String content, String etag) {
|
||||||
|
return new ConditionalRequestResult(true, false, content, etag, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConditionalRequestResult notModified() {
|
||||||
|
return new ConditionalRequestResult(true, true, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConditionalRequestResult error(String error) {
|
||||||
|
return new ConditionalRequestResult(false, false, null, null, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network metrics
|
||||||
|
*/
|
||||||
|
public static class NetworkMetrics {
|
||||||
|
public int totalRequests = 0;
|
||||||
|
public int cachedResponses = 0;
|
||||||
|
public int networkResponses = 0;
|
||||||
|
public int errors = 0;
|
||||||
|
|
||||||
|
public void recordRequest(String url, int responseCode, boolean fromCache) {
|
||||||
|
totalRequests++;
|
||||||
|
if (fromCache) {
|
||||||
|
cachedResponses++;
|
||||||
|
} else {
|
||||||
|
networkResponses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordError(String url, String error) {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset() {
|
||||||
|
totalRequests = 0;
|
||||||
|
cachedResponses = 0;
|
||||||
|
networkResponses = 0;
|
||||||
|
errors = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getCacheHitRatio() {
|
||||||
|
if (totalRequests == 0) return 0.0;
|
||||||
|
return (double) cachedResponses / totalRequests;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache statistics
|
||||||
|
*/
|
||||||
|
public static class CacheStatistics {
|
||||||
|
public final int totalETags;
|
||||||
|
public final int expiredETags;
|
||||||
|
public final int validETags;
|
||||||
|
|
||||||
|
public CacheStatistics(int totalETags, int expiredETags, int validETags) {
|
||||||
|
this.totalETags = totalETags;
|
||||||
|
this.expiredETags = expiredETags;
|
||||||
|
this.validETags = validETags;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("CacheStatistics{total=%d, expired=%d, valid=%d}",
|
||||||
|
totalETags, expiredETags, validETags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ public class DailyNotificationFetcher {
|
|||||||
private final DailyNotificationStorage storage;
|
private final DailyNotificationStorage storage;
|
||||||
private final WorkManager workManager;
|
private final WorkManager workManager;
|
||||||
|
|
||||||
|
// ETag manager for efficient fetching
|
||||||
|
private final DailyNotificationETagManager etagManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
@@ -53,6 +56,9 @@ public class DailyNotificationFetcher {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.workManager = WorkManager.getInstance(context);
|
this.workManager = WorkManager.getInstance(context);
|
||||||
|
this.etagManager = new DailyNotificationETagManager(storage);
|
||||||
|
|
||||||
|
Log.d(TAG, "DailyNotificationFetcher initialized with ETag support");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,54 +174,38 @@ public class DailyNotificationFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch content from network with timeout
|
* Fetch content from network with ETag support
|
||||||
*
|
*
|
||||||
* @return Fetched content or null if failed
|
* @return Fetched content or null if failed
|
||||||
*/
|
*/
|
||||||
private NotificationContent fetchFromNetwork() {
|
private NotificationContent fetchFromNetwork() {
|
||||||
HttpURLConnection connection = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create connection to content endpoint
|
Log.d(TAG, "Fetching content from network with ETag support");
|
||||||
URL url = new URL(getContentEndpoint());
|
|
||||||
connection = (HttpURLConnection) url.openConnection();
|
|
||||||
|
|
||||||
// Set timeout
|
// Get content endpoint URL
|
||||||
connection.setConnectTimeout(NETWORK_TIMEOUT_MS);
|
String contentUrl = getContentEndpoint();
|
||||||
connection.setReadTimeout(NETWORK_TIMEOUT_MS);
|
|
||||||
connection.setRequestMethod("GET");
|
|
||||||
|
|
||||||
// Add headers
|
// Make conditional request with ETag
|
||||||
connection.setRequestProperty("User-Agent", "TimeSafari-DailyNotification/1.0");
|
DailyNotificationETagManager.ConditionalRequestResult result =
|
||||||
connection.setRequestProperty("Accept", "application/json");
|
etagManager.makeConditionalRequest(contentUrl);
|
||||||
|
|
||||||
// Connect and check response
|
if (result.success) {
|
||||||
int responseCode = connection.getResponseCode();
|
if (result.isFromCache) {
|
||||||
|
Log.d(TAG, "Content not modified (304) - using cached content");
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
return storage.getLastNotification();
|
||||||
// Parse response and create notification content
|
} else {
|
||||||
NotificationContent content = parseNetworkResponse(connection);
|
Log.d(TAG, "New content available (200) - parsing response");
|
||||||
|
return parseNetworkResponse(result.content);
|
||||||
if (content != null) {
|
|
||||||
Log.d(TAG, "Content fetched from network successfully");
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Network request failed with response code: " + responseCode);
|
Log.w(TAG, "Conditional request failed: " + result.error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.e(TAG, "Network error during content fetch", e);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Unexpected error during network fetch", e);
|
Log.e(TAG, "Error during network fetch with ETag", e);
|
||||||
} finally {
|
return null;
|
||||||
if (connection != null) {
|
|
||||||
connection.disconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,6 +233,34 @@ public class DailyNotificationFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse network response string into notification content
|
||||||
|
*
|
||||||
|
* @param responseString Response content as string
|
||||||
|
* @return Parsed notification content or null if parsing failed
|
||||||
|
*/
|
||||||
|
private NotificationContent parseNetworkResponse(String responseString) {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Parsing network response string");
|
||||||
|
|
||||||
|
// This is a simplified parser - in production you'd use a proper JSON parser
|
||||||
|
// For now, we'll create a placeholder content
|
||||||
|
|
||||||
|
NotificationContent content = new NotificationContent();
|
||||||
|
content.setTitle("Daily Update");
|
||||||
|
content.setBody("Your daily notification is ready");
|
||||||
|
content.setScheduledTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
|
||||||
|
content.setFetchTime(System.currentTimeMillis());
|
||||||
|
|
||||||
|
Log.d(TAG, "Network response parsed successfully");
|
||||||
|
return content;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error parsing network response string", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get fallback content when network fetch fails
|
* Get fallback content when network fetch fails
|
||||||
*
|
*
|
||||||
@@ -361,4 +379,45 @@ public class DailyNotificationFetcher {
|
|||||||
storage.getLastFetchTime(),
|
storage.getLastFetchTime(),
|
||||||
isFetchWorkScheduled() ? "yes" : "no");
|
isFetchWorkScheduled() ? "yes" : "no");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ETag manager for external access
|
||||||
|
*
|
||||||
|
* @return ETag manager instance
|
||||||
|
*/
|
||||||
|
public DailyNotificationETagManager getETagManager() {
|
||||||
|
return etagManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network efficiency metrics
|
||||||
|
*
|
||||||
|
* @return Network metrics
|
||||||
|
*/
|
||||||
|
public DailyNotificationETagManager.NetworkMetrics getNetworkMetrics() {
|
||||||
|
return etagManager.getMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ETag cache statistics
|
||||||
|
*
|
||||||
|
* @return Cache statistics
|
||||||
|
*/
|
||||||
|
public DailyNotificationETagManager.CacheStatistics getCacheStatistics() {
|
||||||
|
return etagManager.getCacheStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean expired ETags
|
||||||
|
*/
|
||||||
|
public void cleanExpiredETags() {
|
||||||
|
etagManager.cleanExpiredETags();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset network metrics
|
||||||
|
*/
|
||||||
|
public void resetNetworkMetrics() {
|
||||||
|
etagManager.resetMetrics();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user