226 lines
6.6 KiB
Dart
226 lines
6.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'billing_models.dart';
|
|
|
|
/// Item in the durable purchase verification queue.
|
|
@immutable
|
|
class QueuedVerificationItem {
|
|
final String purchaseId;
|
|
final String productId;
|
|
final String purchaseToken;
|
|
final String? purchaseTokenHash;
|
|
final int attemptCount;
|
|
final DateTime queuedAt;
|
|
final DateTime nextRetryAt;
|
|
|
|
const QueuedVerificationItem({
|
|
required this.purchaseId,
|
|
required this.productId,
|
|
required this.purchaseToken,
|
|
this.purchaseTokenHash,
|
|
this.attemptCount = 0,
|
|
required this.queuedAt,
|
|
required this.nextRetryAt,
|
|
});
|
|
|
|
QueuedVerificationItem copyWithRetry({
|
|
required int attemptCount,
|
|
required DateTime nextRetryAt,
|
|
}) {
|
|
return QueuedVerificationItem(
|
|
purchaseId: purchaseId,
|
|
productId: productId,
|
|
purchaseToken: purchaseToken,
|
|
purchaseTokenHash: purchaseTokenHash,
|
|
attemptCount: attemptCount,
|
|
queuedAt: queuedAt,
|
|
nextRetryAt: nextRetryAt,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'purchaseId': purchaseId,
|
|
'productId': productId,
|
|
'purchaseToken': purchaseToken,
|
|
'purchaseTokenHash': purchaseTokenHash,
|
|
'attemptCount': attemptCount,
|
|
'queuedAt': queuedAt.toIso8601String(),
|
|
'nextRetryAt': nextRetryAt.toIso8601String(),
|
|
};
|
|
|
|
factory QueuedVerificationItem.fromJson(Map<String, dynamic> json) {
|
|
return QueuedVerificationItem(
|
|
purchaseId: json['purchaseId'] as String,
|
|
productId: json['productId'] as String,
|
|
purchaseToken: json['purchaseToken'] as String,
|
|
purchaseTokenHash: json['purchaseTokenHash'] as String?,
|
|
attemptCount: json['attemptCount'] as int? ?? 0,
|
|
queuedAt: DateTime.parse(json['queuedAt'] as String),
|
|
nextRetryAt: DateTime.parse(json['nextRetryAt'] as String),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Abstract contract for the durable verification queue.
|
|
abstract class DurableVerificationQueue {
|
|
Future<List<QueuedVerificationItem>> loadQueue();
|
|
Future<void> enqueue(StorePurchase purchase);
|
|
Future<void> dequeue(String purchaseId);
|
|
Future<void> recordRetry(String purchaseId, {required Duration backoffDelay});
|
|
Future<void> clear();
|
|
}
|
|
|
|
/// In-memory queue for deterministic testing.
|
|
class InMemoryVerificationQueue implements DurableVerificationQueue {
|
|
final Map<String, QueuedVerificationItem> _items = {};
|
|
|
|
@override
|
|
Future<List<QueuedVerificationItem>> loadQueue() async {
|
|
return _items.values.toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> enqueue(StorePurchase purchase) async {
|
|
final token = purchase.purchaseToken;
|
|
if (token == null || token.isEmpty) return;
|
|
|
|
if (!_items.containsKey(purchase.purchaseId)) {
|
|
_items[purchase.purchaseId] = QueuedVerificationItem(
|
|
purchaseId: purchase.purchaseId,
|
|
productId: purchase.productId,
|
|
purchaseToken: token,
|
|
purchaseTokenHash: purchase.purchaseTokenHash,
|
|
queuedAt: DateTime.now(),
|
|
nextRetryAt: DateTime.now(),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> dequeue(String purchaseId) async {
|
|
_items.remove(purchaseId);
|
|
}
|
|
|
|
@override
|
|
Future<void> recordRetry(
|
|
String purchaseId, {
|
|
required Duration backoffDelay,
|
|
}) async {
|
|
final item = _items[purchaseId];
|
|
if (item != null) {
|
|
_items[purchaseId] = item.copyWithRetry(
|
|
attemptCount: item.attemptCount + 1,
|
|
nextRetryAt: DateTime.now().add(backoffDelay),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> clear() async {
|
|
_items.clear();
|
|
}
|
|
}
|
|
|
|
/// Production implementation backed by FlutterSecureStorage.
|
|
/// Isolates raw purchase tokens from general application state.
|
|
class SecureDurableVerificationQueue implements DurableVerificationQueue {
|
|
static const String _storageKey = 'motorsignal_pending_verification_queue_v1';
|
|
final FlutterSecureStorage _secureStorage;
|
|
|
|
const SecureDurableVerificationQueue({FlutterSecureStorage? secureStorage})
|
|
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
|
|
|
|
@override
|
|
Future<List<QueuedVerificationItem>> loadQueue() async {
|
|
try {
|
|
final raw = await _secureStorage.read(key: _storageKey);
|
|
if (raw == null || raw.trim().isEmpty) return [];
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! List) return [];
|
|
return decoded
|
|
.map(
|
|
(item) => QueuedVerificationItem.fromJson(
|
|
Map<String, dynamic>.from(item as Map),
|
|
),
|
|
)
|
|
.toList();
|
|
} catch (e) {
|
|
debugPrint('[SecureVerificationQueue] Failed to load queue: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> enqueue(StorePurchase purchase) async {
|
|
final token = purchase.purchaseToken;
|
|
if (token == null || token.trim().isEmpty) return;
|
|
|
|
final current = await loadQueue();
|
|
if (current.any((i) => i.purchaseId == purchase.purchaseId)) {
|
|
return; // Deduplicate
|
|
}
|
|
|
|
final updated = List<QueuedVerificationItem>.from(current)
|
|
..add(
|
|
QueuedVerificationItem(
|
|
purchaseId: purchase.purchaseId,
|
|
productId: purchase.productId,
|
|
purchaseToken: token.trim(),
|
|
purchaseTokenHash: purchase.purchaseTokenHash,
|
|
queuedAt: DateTime.now(),
|
|
nextRetryAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
await _persistQueue(updated);
|
|
}
|
|
|
|
@override
|
|
Future<void> dequeue(String purchaseId) async {
|
|
final current = await loadQueue();
|
|
final updated = current.where((i) => i.purchaseId != purchaseId).toList();
|
|
await _persistQueue(updated);
|
|
}
|
|
|
|
@override
|
|
Future<void> recordRetry(
|
|
String purchaseId, {
|
|
required Duration backoffDelay,
|
|
}) async {
|
|
final current = await loadQueue();
|
|
final index = current.indexWhere((i) => i.purchaseId == purchaseId);
|
|
if (index == -1) return;
|
|
|
|
final item = current[index];
|
|
final updated = List<QueuedVerificationItem>.from(current);
|
|
updated[index] = item.copyWithRetry(
|
|
attemptCount: item.attemptCount + 1,
|
|
nextRetryAt: DateTime.now().add(backoffDelay),
|
|
);
|
|
|
|
await _persistQueue(updated);
|
|
}
|
|
|
|
@override
|
|
Future<void> clear() async {
|
|
try {
|
|
await _secureStorage.delete(key: _storageKey);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _persistQueue(List<QueuedVerificationItem> items) async {
|
|
try {
|
|
if (items.isEmpty) {
|
|
await _secureStorage.delete(key: _storageKey);
|
|
} else {
|
|
final serialized = jsonEncode(items.map((i) => i.toJson()).toList());
|
|
await _secureStorage.write(key: _storageKey, value: serialized);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[SecureVerificationQueue] Failed to persist queue: $e');
|
|
}
|
|
}
|
|
}
|