feat: establish entitlement domain and feature access foundation

This commit is contained in:
Developer 2026-09-03 18:22:21 -04:00
parent 885d562690
commit ee5fc42b37
6 changed files with 1027 additions and 78 deletions

280
AGENTS.md
View File

@ -1,78 +1,152 @@
# MotorSignal — Agent Development Rules # MotorSignal — Agent Development Rules
## Project Identity ## Project Identity
MotorSignal is a Flutter motorcycle telemetry and ride-analysis application. MotorSignal is a Flutter motorcycle telemetry, ride-recording, performance-analysis, and cloud-synchronization application.
Before modifying code, agents MUST inspect the relevant existing architecture and Before modifying code, agents MUST inspect the relevant existing architecture, providers, services, persistence paths, and tests and understand the current implementation.
tests and understand the current implementation.
Do not infer architecture from filenames or documentation alone when the production code can be inspected directly.
## Required Development Workflow ## Required Development Workflow
Before making changes: Before making changes:
1. Inspect the relevant source files. 1. Confirm the current Git branch and working-tree state.
2. Identify existing services, providers, models, repositories, and tests. 2. Identify any pre-existing modified/untracked files and do not alter them unless they are explicitly part of the task.
3. Do not duplicate existing functionality. 3. Inspect the relevant source files.
4. Make the smallest safe change necessary. 4. Identify existing services, providers, models, repositories, database helpers, and tests.
5. Preserve existing public interfaces unless the task explicitly requires change. 5. Search for all production callers before replacing or removing an implementation.
6. Add or update tests for behavioral changes. 6. Do not duplicate existing functionality.
7. Run static analysis. 7. Make the smallest safe change necessary.
8. Run the relevant test suite. 8. Preserve existing public interfaces unless the task explicitly requires a change.
9. Run the complete test suite before declaring the task complete. 9. Add or update tests for behavioral changes.
10. Report exactly what changed and what verification passed. 10. Run the relevant focused tests first when appropriate.
11. Run the complete test suite.
12. Run static analysis.
13. Build the debug APK for changes that can affect production compilation/runtime wiring.
14. Report exactly what changed and what verification passed.
15. Do not commit unless explicitly authorized.
If the requested change conflicts with an existing architectural contract, stop and report the conflict before broadening scope.
## Telemetry Freeze Boundary ## Telemetry Freeze Boundary
The following subsystem is FROZEN unless the task explicitly requires The following subsystem is FROZEN unless the task explicitly requires modification and the user authorizes that change:
modification and the user approves the architectural change:
- TelemetryService * TelemetryService
- SensorFusionService * SensorFusionService
- RideStateMachine * RideStateMachine telemetry/motion semantics
Changes to RideStateMachine that are unrelated to telemetry semantics may be permitted when explicitly required by the task, but existing ride lifecycle behavior must be preserved unless the task specifically authorizes otherwise.
### TelemetryService ### TelemetryService
Authoritative telemetry acquisition runs at 20 Hz / 50 ms. Authoritative telemetry acquisition runs at 20 Hz / 50 ms.
Do not: Do not:
- change acquisition cadence
- introduce competing telemetry clocks * change acquisition cadence
- duplicate sensor acquisition * introduce competing telemetry clocks
- bypass the authoritative telemetry pipeline * duplicate sensor acquisition
- introduce UI-driven polling of sensors * bypass the authoritative telemetry pipeline
* introduce UI-driven polling of sensors
All consumers must use the existing telemetry architecture. All consumers must use the existing telemetry architecture.
### SensorFusionService ### SensorFusionService
Sensor fusion is authoritative for derived orientation/lean data. Sensor fusion is authoritative for derived orientation and lean data.
Do not introduce an independent lean-angle calculation without explicit Do not introduce an independent lean-angle calculation without explicit architectural justification and regression tests.
architectural justification and regression tests.
### RideStateMachine ### RideStateMachine
Preserve existing movement/crash/recovery semantics. Preserve existing movement, crash, lifecycle, interrupted-ride recovery, and state-transition semantics.
The 3.0 MPH moving gate and kickstand isolation behavior are architectural The 3.0 MPH moving gate and kickstand isolation behavior are architectural contracts unless explicitly changed by the user.
contracts unless explicitly changed by the user.
Ride lifecycle changes must include regression coverage.
## Database Freeze Boundary ## Database Freeze Boundary
AppDatabase is currently Schema 5. AppDatabase is currently Schema 7.
Do not modify database schema, migrations, event semantics, or persistence Do not modify database schema, migrations, event semantics, or persistence behavior casually.
behavior casually.
Any schema change MUST include: Any schema change MUST include:
- migration * an explicit schema-version decision
- migration tests * migration logic where required
- backward compatibility consideration * migration tests
- verification against existing ride datasets * backward-compatibility consideration
* verification against existing ride datasets
* consideration for legacy/null data
Never delete or rewrite historical ride data during feature development. Never delete, destructively migrate, or rewrite historical ride data during normal feature development.
Use existing database helpers where available instead of duplicating raw insert/update behavior.
Stable ride identity (`cloudRideId`) is part of the persistence contract. Do not regenerate or replace an existing ride identity.
## Cloud / Offline Sync Architecture
MotorSignal uses an offline-first cloud synchronization architecture.
The authoritative automatic completed-ride synchronization path is:
RideStateMachine
→ syncObserverProvider
→ SyncManager
→ SyncOutbox
→ FirebaseSyncProvider
→ Cloudflare R2
→ Firestore
Do not introduce another automatic ride-upload queue or bypass SyncManager/SyncOutbox for normal completed-ride synchronization.
### SyncManager / SyncOutbox
SyncManager and SyncOutbox are authoritative for durable automatic cloud synchronization.
Preserve:
* durable queued work
* retry behavior
* authentication-required state
* restart recovery
* duplicate suppression
* account scoping
* offline operation
Do not replace durable queueing with an in-memory-only queue.
### FirebaseSyncProvider
FirebaseSyncProvider is the authoritative MotorCloud upload implementation.
Normal cloud synchronization must preserve:
* stable cloudRideId
* R2 telemetry-bundle identity
* Firestore ride identity
* retry/idempotency behavior
Legacy compatibility or explicit developer-export services must not become competing automatic synchronization paths.
### GitHub Sync / Export
GitHub-related upload/export functionality may remain for explicit developer or compatibility workflows.
Do not route normal automatic completed-ride synchronization through a legacy GitHubSyncService queue.
## Performance Data
Performance timing data such as 0–60 and 0–100 results must remain associated with the ride that produced it when recorded during an active ride.
Do not silently discard or detach ride-specific performance data from cloud/export payloads.
Legacy standalone performance runs with no ride association must remain valid where supported by the existing design.
## Forensic / Export Freeze Boundary ## Forensic / Export Freeze Boundary
@ -80,14 +154,30 @@ FieldTestExportService is authoritative for forensic exports.
Preserve: Preserve:
- CSV schema * CSV schema
- JSON session schema 1 * JSON session schema 1
- GPX export * GPX export
- timestamp semantics * timestamp semantics
- event ordering * event ordering
- sub-second telemetry fidelity * sub-second telemetry fidelity
Feature/UI work must not silently alter exported historical data. Feature or UI work must not silently alter exported historical data.
Any intentional export-schema change must be explicitly authorized and versioned appropriately.
## Authentication
Authentication must not block local ride recording.
Preserve offline/local functionality when:
* signed out
* Firebase is unavailable
* network is unavailable
Do not make cloud authentication a prerequisite for collecting or storing local ride data unless explicitly required.
Anonymous/authentication transition behavior must not cause ride loss.
## State Management ## State Management
@ -95,37 +185,102 @@ Follow the project's existing Riverpod architecture.
Do not introduce another state-management framework. Do not introduce another state-management framework.
Prefer existing providers/services over creating parallel state containers. Prefer existing providers and services over creating parallel state containers.
Be careful with provider lifecycle and `autoDispose` behavior for long-lived services, observers, synchronization, and ride-state listeners.
## UI Development ## UI Development
UI features must consume existing domain/application services. UI features must consume existing domain/application services.
Do not place telemetry acquisition, persistence, sensor fusion, or business Do not place telemetry acquisition, persistence, sensor fusion, synchronization, or substantial business logic directly inside widgets.
logic directly inside widgets.
Support both portrait and landscape layouts where applicable. Support both portrait and landscape layouts where applicable.
Do not make UI refresh frequency drive telemetry or persistence frequency.
## Testing ## Testing
Every behavioral change requires appropriate automated coverage. Every behavioral change requires appropriate automated coverage.
Before completion: Prefer behavioral tests over source-text assertions when practical.
dart analyze lib test Before completion, run:
flutter test
A task is NOT considered complete if tests fail, analysis reports new issues, ```text
or existing regression datasets change unexpectedly. flutter test
flutter analyze
flutter build apk --debug
```
Focused tests may be run first during development.
A task is NOT considered complete if:
* tests fail
* analysis introduces new errors
* new warnings are introduced without justification
* the debug APK no longer builds when the task can affect compilation/runtime wiring
* existing regression datasets change unexpectedly
Pre-existing known warnings/info may remain if they are outside task scope, but they must be reported accurately.
Never claim physical motorcycle/device validation was performed unless it actually occurred.
## Git Safety ## Git Safety
Never reset, force-push, rebase, delete branches, delete tags, or rewrite Never:
history unless explicitly requested.
Create a checkpoint commit before significant architectural changes. * reset unrelated work
* force-push
* rewrite history
* rebase unless explicitly requested
* delete branches
* delete tags
* discard pre-existing user changes
* stage unrelated files
* commit unless explicitly authorized
Do not modify unrelated files. Create or verify a checkpoint before significant architectural changes.
If the working tree is dirty before a task:
1. identify the pre-existing files
2. preserve them
3. do not include them in task changes
4. clearly separate them in the final report
Use per-command Git safe-directory overrides when necessary rather than changing global Git configuration unless explicitly requested.
## Dependency / Toolchain Safety
Do not perform broad dependency or toolchain upgrades to solve a narrow problem.
Prefer the smallest compatible dependency change.
Do not run broad package upgrades such as `flutter pub upgrade` unless explicitly required.
When changing dependencies:
* identify the exact reason
* inspect lockfile impact
* report exact version changes
* verify tests, analysis, and APK build
Do not raise Android compileSdk, targetSdk, AGP, Gradle, Kotlin, or Java versions merely to satisfy one package without first investigating a narrower compatible solution.
## Documentation Accuracy
Treat documentation as guidance, not proof of current production behavior.
When documentation conflicts with production code:
1. verify behavior from source/tests
2. preserve the correct production behavior
3. report stale documentation
4. update documentation only if it is within task scope
Do not silently rely on stale schema versions, timing values, provider wiring, or architecture descriptions.
## Agent Behavior ## Agent Behavior
@ -135,5 +290,12 @@ Prefer surgical patches over broad rewrites.
Do not "fix" unrelated problems discovered during a task. Do not "fix" unrelated problems discovered during a task.
If an architectural conflict is discovered, stop and report it rather than Do not silently expand scope.
silently changing a frozen subsystem.
Do not claim validation that was not actually performed.
Separate confirmed defects from suspected risks.
If an architectural conflict is discovered, stop and report it rather than silently changing a frozen subsystem.
When a task explicitly says STOP after reporting, do not proceed into the next stage.

View File

@ -1,7 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'entitlement_state.dart'; import 'entitlement_state.dart';
import 'entitlement_storage.dart';
import 'feature_access_service.dart';
/// Temporary launch-safe entitlement provider. It defaults to Free until the /// Provider for the physical entitlement storage contract.
/// Google Play Billing implementation is wired in. Keeping this boundary now /// Can be overridden in tests with [InMemoryEntitlementStorage].
/// prevents future feature code from depending directly on billing SDK types. final entitlementStorageProvider = Provider<EntitlementStorage>((ref) {
final entitlementProvider = Provider<EntitlementState>((ref) => const EntitlementState()); return const SecureEntitlementStorage();
});
/// Authoritative entitlement state provider.
/// Defaults to [EntitlementState.free] until the billing coordinator or cache loader runs.
/// In Stage 4B-2, this will be wired to the billing lifecycle coordinator.
final entitlementProvider = StateProvider<EntitlementState>((ref) {
return const EntitlementState.free();
});
/// Centralized provider for checking feature accessibility.
/// Consumers watch this provider and call [FeatureAccessService.canAccess].
final featureAccessProvider = Provider<FeatureAccessService>((ref) {
final state = ref.watch(entitlementProvider);
return FeatureAccessService(state);
});

View File

@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
enum MotorSignalPlan { /// Supported commercial product plans for MotorSignal.
free, enum MotorSignalPlan { free, proMonthly, proLifetime }
proMonthly,
proLifetime,
}
/// Lifecycle and billing entitlement states.
enum MotorSignalEntitlementStatus { enum MotorSignalEntitlementStatus {
free, free,
proMonthlyActive, proMonthlyActive,
@ -17,23 +15,198 @@ enum MotorSignalEntitlementStatus {
syncPending, syncPending,
} }
/// Central capability model. UI and services should depend on capabilities, /// Verification / freshness state of store-asserted entitlement.
/// not Google Play product IDs or scattered `isPro` checks. enum EntitlementVerificationStatus {
/// Unverified or default state before store inquiry.
unverified,
/// Confirmed valid by app store or server verification.
verified,
/// Validated locally under defined offline grace rules.
offlineGrace,
/// Explicitly expired subscription.
expired,
/// Revoked or refunded by store/issuer.
revoked,
}
/// Origin of purchase assertion.
enum PurchaseSource { none, googlePlay, offlineCache }
/// Central capability model and immutable entitlement state.
/// UI and services depend on capabilities, not store product IDs or scattered checks.
@immutable @immutable
class EntitlementState { class EntitlementState {
final MotorSignalPlan plan; final MotorSignalPlan plan;
final MotorSignalEntitlementStatus status; final MotorSignalEntitlementStatus status;
final EntitlementVerificationStatus verificationStatus;
final PurchaseSource source;
final String? productId;
final String? purchaseTokenHash;
final DateTime? expiresAt; final DateTime? expiresAt;
final DateTime? lastVerifiedAt;
const EntitlementState({ const EntitlementState({
this.plan = MotorSignalPlan.free, this.plan = MotorSignalPlan.free,
this.status = MotorSignalEntitlementStatus.free, this.status = MotorSignalEntitlementStatus.free,
this.verificationStatus = EntitlementVerificationStatus.unverified,
this.source = PurchaseSource.none,
this.productId,
this.purchaseTokenHash,
this.expiresAt, this.expiresAt,
this.lastVerifiedAt,
}); });
bool get isPro => plan != MotorSignalPlan.free && /// Factory creating normalized default Free tier.
status != MotorSignalEntitlementStatus.proMonthlyExpired; const EntitlementState.free()
: plan = MotorSignalPlan.free,
status = MotorSignalEntitlementStatus.free,
verificationStatus = EntitlementVerificationStatus.verified,
source = PurchaseSource.none,
productId = null,
purchaseTokenHash = null,
expiresAt = null,
lastVerifiedAt = null;
/// Factory creating normalized active Pro Monthly tier.
EntitlementState.proMonthlyActive({
this.productId,
this.purchaseTokenHash,
this.expiresAt,
DateTime? lastVerifiedAt,
this.source = PurchaseSource.googlePlay,
}) : plan = MotorSignalPlan.proMonthly,
status = MotorSignalEntitlementStatus.proMonthlyActive,
verificationStatus = EntitlementVerificationStatus.verified,
lastVerifiedAt = lastVerifiedAt ?? DateTime.now();
/// Factory creating normalized active Pro Lifetime tier.
EntitlementState.proLifetime({
this.productId,
this.purchaseTokenHash,
DateTime? lastVerifiedAt,
this.source = PurchaseSource.googlePlay,
}) : plan = MotorSignalPlan.proLifetime,
status = MotorSignalEntitlementStatus.proLifetime,
verificationStatus = EntitlementVerificationStatus.verified,
expiresAt = null,
lastVerifiedAt = lastVerifiedAt ?? DateTime.now();
/// Factory normalizing raw inputs to prevent invalid or contradictory combinations.
factory EntitlementState.normalized({
required MotorSignalPlan plan,
required MotorSignalEntitlementStatus status,
EntitlementVerificationStatus verificationStatus =
EntitlementVerificationStatus.unverified,
PurchaseSource source = PurchaseSource.none,
String? productId,
String? purchaseTokenHash,
DateTime? expiresAt,
DateTime? lastVerifiedAt,
}) {
// Normalization rules:
// 1. Free plan must not possess Pro status, active expiration, or Pro product IDs.
if (plan == MotorSignalPlan.free) {
return EntitlementState(
plan: MotorSignalPlan.free,
status: MotorSignalEntitlementStatus.free,
verificationStatus:
verificationStatus == EntitlementVerificationStatus.revoked
? EntitlementVerificationStatus.revoked
: EntitlementVerificationStatus.verified,
source: PurchaseSource.none,
productId: null,
purchaseTokenHash: null,
expiresAt: null,
lastVerifiedAt: lastVerifiedAt,
);
}
// 2. Revoked verification forces expired/revoked standing regardless of plan.
if (verificationStatus == EntitlementVerificationStatus.revoked) {
return EntitlementState(
plan: plan,
status: plan == MotorSignalPlan.proLifetime
? MotorSignalEntitlementStatus.unknown
: MotorSignalEntitlementStatus.proMonthlyExpired,
verificationStatus: EntitlementVerificationStatus.revoked,
source: source,
productId: productId,
purchaseTokenHash: purchaseTokenHash,
expiresAt: expiresAt,
lastVerifiedAt: lastVerifiedAt,
);
}
// 3. Lifetime plan normalization: status must be proLifetime, expiresAt is null.
if (plan == MotorSignalPlan.proLifetime) {
final isSyncPending = status == MotorSignalEntitlementStatus.syncPending;
return EntitlementState(
plan: MotorSignalPlan.proLifetime,
status: isSyncPending
? MotorSignalEntitlementStatus.syncPending
: MotorSignalEntitlementStatus.proLifetime,
verificationStatus: verificationStatus,
source: source,
productId: productId,
purchaseTokenHash: purchaseTokenHash,
expiresAt: null,
lastVerifiedAt: lastVerifiedAt,
);
}
// 4. Pro Monthly: check explicit expiration if past expiresAt.
final now = DateTime.now();
var resolvedStatus = status;
var resolvedVerification = verificationStatus;
if (expiresAt != null && now.isAfter(expiresAt)) {
if (status == MotorSignalEntitlementStatus.proMonthlyActive) {
// Expired active subscription
resolvedStatus = MotorSignalEntitlementStatus.proMonthlyExpired;
resolvedVerification = EntitlementVerificationStatus.expired;
}
}
return EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: resolvedStatus,
verificationStatus: resolvedVerification,
source: source,
productId: productId,
purchaseTokenHash: purchaseTokenHash,
expiresAt: expiresAt,
lastVerifiedAt: lastVerifiedAt,
);
}
/// Whether user currently holds effective Pro entitlement.
/// Free plan, expired subscription, revoked verification, or account hold do NOT grant Pro access.
bool get isPro {
if (plan == MotorSignalPlan.free) return false;
if (verificationStatus == EntitlementVerificationStatus.revoked) {
return false;
}
if (verificationStatus == EntitlementVerificationStatus.expired) {
return false;
}
if (status == MotorSignalEntitlementStatus.proMonthlyExpired) return false;
if (status == MotorSignalEntitlementStatus.proMonthlyAccountHold) {
return false;
}
if (status == MotorSignalEntitlementStatus.free) return false;
if (status == MotorSignalEntitlementStatus.unknown) return false;
// Active or grace period allows effective access
return status == MotorSignalEntitlementStatus.proMonthlyActive ||
status == MotorSignalEntitlementStatus.proMonthlyGrace ||
status == MotorSignalEntitlementStatus.proLifetime;
}
// Capability mapping conforming to MOTORSIGNAL_FREE_PRO_SPEC.md
bool get canCloudBackup => true; bool get canCloudBackup => true;
bool get canUnlimitedCloudHistory => isPro; bool get canUnlimitedCloudHistory => isPro;
bool get canMultiDeviceRestore => isPro; bool get canMultiDeviceRestore => isPro;
@ -50,17 +223,110 @@ class EntitlementState {
bool get canCreateGroupRides => isPro; bool get canCreateGroupRides => isPro;
bool get canUseAdvancedGroupRideFeatures => isPro; bool get canUseAdvancedGroupRideFeatures => isPro;
/// The Free cloud allowance is intentionally not hard-coded here. Product /// Free cloud allowance quota. null indicates unconstrained (Pro) or to be determined by backend.
/// economics will define the exact quota before launch.
int? get cloudRideAllowance => isPro ? null : null; int? get cloudRideAllowance => isPro ? null : null;
EntitlementState copyWith({ EntitlementState copyWith({
MotorSignalPlan? plan, MotorSignalPlan? plan,
MotorSignalEntitlementStatus? status, MotorSignalEntitlementStatus? status,
EntitlementVerificationStatus? verificationStatus,
PurchaseSource? source,
String? productId,
String? purchaseTokenHash,
DateTime? expiresAt, DateTime? expiresAt,
DateTime? lastVerifiedAt,
}) => EntitlementState( }) => EntitlementState(
plan: plan ?? this.plan, plan: plan ?? this.plan,
status: status ?? this.status, status: status ?? this.status,
verificationStatus: verificationStatus ?? this.verificationStatus,
source: source ?? this.source,
productId: productId ?? this.productId,
purchaseTokenHash: purchaseTokenHash ?? this.purchaseTokenHash,
expiresAt: expiresAt ?? this.expiresAt, expiresAt: expiresAt ?? this.expiresAt,
lastVerifiedAt: lastVerifiedAt ?? this.lastVerifiedAt,
); );
Map<String, dynamic> toJson() => {
'version': 1,
'plan': plan.name,
'status': status.name,
'verificationStatus': verificationStatus.name,
'source': source.name,
'productId': productId,
'purchaseTokenHash': purchaseTokenHash,
'expiresAt': expiresAt?.toIso8601String(),
'lastVerifiedAt': lastVerifiedAt?.toIso8601String(),
};
factory EntitlementState.fromJson(Map<String, dynamic> json) {
final version = json['version'] as int? ?? 1;
if (version > 1) {
// Forward-compatibility safeguard for future cache schema versions
throw const FormatException('Unsupported entitlement cache version');
}
final plan = MotorSignalPlan.values.firstWhere(
(e) => e.name == json['plan'],
orElse: () => MotorSignalPlan.free,
);
final status = MotorSignalEntitlementStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => MotorSignalEntitlementStatus.free,
);
final verificationStatus = EntitlementVerificationStatus.values.firstWhere(
(e) => e.name == json['verificationStatus'],
orElse: () => EntitlementVerificationStatus.unverified,
);
final source = PurchaseSource.values.firstWhere(
(e) => e.name == json['source'],
orElse: () => PurchaseSource.none,
);
final expiresAt = json['expiresAt'] != null
? DateTime.tryParse(json['expiresAt'] as String)
: null;
final lastVerifiedAt = json['lastVerifiedAt'] != null
? DateTime.tryParse(json['lastVerifiedAt'] as String)
: null;
return EntitlementState.normalized(
plan: plan,
status: status,
verificationStatus: verificationStatus,
source: source,
productId: json['productId'] as String?,
purchaseTokenHash: json['purchaseTokenHash'] as String?,
expiresAt: expiresAt,
lastVerifiedAt: lastVerifiedAt,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is EntitlementState &&
runtimeType == other.runtimeType &&
plan == other.plan &&
status == other.status &&
verificationStatus == other.verificationStatus &&
source == other.source &&
productId == other.productId &&
purchaseTokenHash == other.purchaseTokenHash &&
expiresAt == other.expiresAt &&
lastVerifiedAt == other.lastVerifiedAt;
@override
int get hashCode =>
plan.hashCode ^
status.hashCode ^
verificationStatus.hashCode ^
source.hashCode ^
productId.hashCode ^
purchaseTokenHash.hashCode ^
expiresAt.hashCode ^
lastVerifiedAt.hashCode;
} }

View File

@ -0,0 +1,94 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'entitlement_state.dart';
/// Contract defining persistence operations for cached entitlement state.
/// This abstraction isolates the billing coordinator and domain from physical storage.
abstract class EntitlementStorage {
/// Reads cached entitlement state, returning [EntitlementState.free] if none exists or invalid.
Future<EntitlementState> read();
/// Persists [state] to cache.
Future<void> write(EntitlementState state);
/// Clears cached entitlement, reverting cache to default.
Future<void> clear();
}
/// In-memory implementation of [EntitlementStorage] for unit testing.
class InMemoryEntitlementStorage implements EntitlementStorage {
EntitlementState _state;
InMemoryEntitlementStorage([this._state = const EntitlementState.free()]);
@override
Future<EntitlementState> read() async => _state;
@override
Future<void> write(EntitlementState state) async {
_state = state;
}
@override
Future<void> clear() async {
_state = const EntitlementState.free();
}
}
/// Production implementation backed by [FlutterSecureStorage].
/// Stores normalized JSON with versioning. Never stores raw Google Play purchase tokens.
class SecureEntitlementStorage implements EntitlementStorage {
static const String storageKey = 'motorsignal_entitlement_v1';
final FlutterSecureStorage _storage;
const SecureEntitlementStorage({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
@override
Future<EntitlementState> read() async {
try {
final raw = await _storage.read(key: storageKey);
if (raw == null || raw.trim().isEmpty) {
return const EntitlementState.free();
}
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
debugPrint('[EntitlementStorage] Cache payload not a valid JSON map');
return const EntitlementState.free();
}
return EntitlementState.fromJson(decoded);
} catch (e, st) {
debugPrint(
'[EntitlementStorage] Error reading cached entitlement: $e\n$st',
);
return const EntitlementState.free();
}
}
@override
Future<void> write(EntitlementState state) async {
try {
final payload = jsonEncode(state.toJson());
await _storage.write(key: storageKey, value: payload);
} catch (e, st) {
debugPrint(
'[EntitlementStorage] Error writing cached entitlement: $e\n$st',
);
}
}
@override
Future<void> clear() async {
try {
await _storage.delete(key: storageKey);
} catch (e, st) {
debugPrint(
'[EntitlementStorage] Error clearing cached entitlement: $e\n$st',
);
}
}
}

View File

@ -0,0 +1,67 @@
import 'entitlement_state.dart';
/// Strongly typed identifiers for all capabilities that can be gated or regulated.
enum MotorSignalFeature {
cloudBackup,
unlimitedCloudHistory,
multiDeviceRestore,
advancedTelemetry,
advancedAcceleration,
advancedBraking,
advancedLean,
advancedAnalytics,
unlimitedHistory,
csvExport,
pdfExport,
useMultipleMotorcycles,
planTrips,
createGroupRides,
useAdvancedGroupRideFeatures,
}
/// Centralized domain service determining access to MotorSignal features.
/// UI and background operations check [canAccess] rather than inspecting raw plan or store fields.
class FeatureAccessService {
final EntitlementState _state;
const FeatureAccessService(this._state);
/// Current underlying entitlement state.
EntitlementState get state => _state;
/// Returns true if the requested [feature] is permitted under current entitlement.
bool canAccess(MotorSignalFeature feature) {
switch (feature) {
case MotorSignalFeature.cloudBackup:
return _state.canCloudBackup;
case MotorSignalFeature.unlimitedCloudHistory:
return _state.canUnlimitedCloudHistory;
case MotorSignalFeature.multiDeviceRestore:
return _state.canMultiDeviceRestore;
case MotorSignalFeature.advancedTelemetry:
return _state.canAdvancedTelemetry;
case MotorSignalFeature.advancedAcceleration:
return _state.canAdvancedAcceleration;
case MotorSignalFeature.advancedBraking:
return _state.canAdvancedBraking;
case MotorSignalFeature.advancedLean:
return _state.canAdvancedLean;
case MotorSignalFeature.advancedAnalytics:
return _state.canAdvancedAnalytics;
case MotorSignalFeature.unlimitedHistory:
return _state.canUnlimitedHistory;
case MotorSignalFeature.csvExport:
return _state.canExportCsv;
case MotorSignalFeature.pdfExport:
return _state.canExportPdf;
case MotorSignalFeature.useMultipleMotorcycles:
return _state.canUseMultipleMotorcycles;
case MotorSignalFeature.planTrips:
return _state.canPlanTrips;
case MotorSignalFeature.createGroupRides:
return _state.canCreateGroupRides;
case MotorSignalFeature.useAdvancedGroupRideFeatures:
return _state.canUseAdvancedGroupRideFeatures;
}
}
}

View File

@ -0,0 +1,343 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('EntitlementState domain rules', () {
test('default constructor creates free tier', () {
const state = EntitlementState();
expect(state.plan, MotorSignalPlan.free);
expect(state.status, MotorSignalEntitlementStatus.free);
expect(state.isPro, isFalse);
expect(state.canCloudBackup, isTrue);
expect(state.canUnlimitedCloudHistory, isFalse);
expect(state.canAdvancedTelemetry, isFalse);
expect(state.canExportCsv, isFalse);
});
test('EntitlementState.free factory normalizes to valid free tier', () {
const state = EntitlementState.free();
expect(state.plan, MotorSignalPlan.free);
expect(state.status, MotorSignalEntitlementStatus.free);
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
expect(state.isPro, isFalse);
expect(state.productId, isNull);
expect(state.expiresAt, isNull);
});
test('Pro Monthly active exposes complete premium capability surface', () {
final now = DateTime.now();
final expiry = now.add(const Duration(days: 30));
final state = EntitlementState.proMonthlyActive(
productId: 'motorsignal_pro_monthly',
purchaseTokenHash: 'abc123hash',
expiresAt: expiry,
lastVerifiedAt: now,
);
expect(state.isPro, isTrue);
expect(state.plan, MotorSignalPlan.proMonthly);
expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive);
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
expect(state.source, PurchaseSource.googlePlay);
expect(state.productId, 'motorsignal_pro_monthly');
expect(state.purchaseTokenHash, 'abc123hash');
expect(state.expiresAt, expiry);
// Verify all Pro capabilities
expect(state.canCloudBackup, isTrue);
expect(state.canUnlimitedCloudHistory, isTrue);
expect(state.canMultiDeviceRestore, isTrue);
expect(state.canAdvancedTelemetry, isTrue);
expect(state.canAdvancedAcceleration, isTrue);
expect(state.canAdvancedBraking, isTrue);
expect(state.canAdvancedLean, isTrue);
expect(state.canAdvancedAnalytics, isTrue);
expect(state.canUnlimitedHistory, isTrue);
expect(state.canExportCsv, isTrue);
expect(state.canExportPdf, isTrue);
expect(state.canUseMultipleMotorcycles, isTrue);
expect(state.canPlanTrips, isTrue);
expect(state.canCreateGroupRides, isTrue);
expect(state.canUseAdvancedGroupRideFeatures, isTrue);
});
test('Pro Monthly Grace provides effective premium access', () {
final state = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyGrace,
verificationStatus: EntitlementVerificationStatus.offlineGrace,
source: PurchaseSource.offlineCache,
productId: 'motorsignal_pro_monthly',
);
expect(state.isPro, isTrue);
expect(state.canAdvancedTelemetry, isTrue);
expect(state.canExportCsv, isTrue);
});
test('Pro Monthly Expired denies premium access', () {
final state = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyExpired,
verificationStatus: EntitlementVerificationStatus.expired,
);
expect(state.isPro, isFalse);
expect(state.canCloudBackup, isTrue);
expect(state.canAdvancedTelemetry, isFalse);
expect(state.canExportCsv, isFalse);
expect(state.canUnlimitedHistory, isFalse);
});
test('Pro Monthly Account Hold denies premium access', () {
final state = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyAccountHold,
);
expect(state.isPro, isFalse);
});
test('Pro Lifetime exposes full capabilities without expiration', () {
final state = EntitlementState.proLifetime(
productId: 'motorsignal_pro_lifetime',
purchaseTokenHash: 'lifetime_hash',
);
expect(state.isPro, isTrue);
expect(state.plan, MotorSignalPlan.proLifetime);
expect(state.status, MotorSignalEntitlementStatus.proLifetime);
expect(state.expiresAt, isNull);
expect(state.canUnlimitedCloudHistory, isTrue);
expect(state.canAdvancedLean, isTrue);
expect(state.canPlanTrips, isTrue);
});
test('Unknown or sync pending statuses do not grant Pro access', () {
const unknownState = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.unknown,
);
expect(unknownState.isPro, isFalse);
const pendingState = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.syncPending,
);
expect(pendingState.isPro, isFalse);
});
});
group('EntitlementState normalization & invalid state prevention', () {
test('normalizes contradictory free plan with pro status to pure free', () {
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.free,
status: MotorSignalEntitlementStatus.proLifetime,
productId: 'some_product',
expiresAt: DateTime.now().add(const Duration(days: 10)),
);
expect(normalized.plan, MotorSignalPlan.free);
expect(normalized.status, MotorSignalEntitlementStatus.free);
expect(normalized.isPro, isFalse);
expect(normalized.productId, isNull);
expect(normalized.expiresAt, isNull);
});
test('normalizes revoked verification status to non-pro status', () {
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.proLifetime,
status: MotorSignalEntitlementStatus.proLifetime,
verificationStatus: EntitlementVerificationStatus.revoked,
);
expect(
normalized.verificationStatus,
EntitlementVerificationStatus.revoked,
);
expect(normalized.isPro, isFalse);
});
test('normalizes expired subscription with past expiresAt date', () {
final pastDate = DateTime.now().subtract(const Duration(days: 2));
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyActive,
expiresAt: pastDate,
);
expect(normalized.status, MotorSignalEntitlementStatus.proMonthlyExpired);
expect(
normalized.verificationStatus,
EntitlementVerificationStatus.expired,
);
expect(normalized.isPro, isFalse);
});
test('normalizes Lifetime plan ensuring null expiration', () {
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.proLifetime,
status: MotorSignalEntitlementStatus.proMonthlyActive,
expiresAt: DateTime.now().add(const Duration(days: 365)),
);
expect(normalized.plan, MotorSignalPlan.proLifetime);
expect(normalized.status, MotorSignalEntitlementStatus.proLifetime);
expect(normalized.expiresAt, isNull);
expect(normalized.isPro, isTrue);
});
});
group('FeatureAccessService exhaustive mapping', () {
test('every MotorSignalFeature enum entry maps to explicit capability', () {
const freeService = FeatureAccessService(EntitlementState.free());
final proService = FeatureAccessService(EntitlementState.proLifetime());
for (final feature in MotorSignalFeature.values) {
// Must execute cleanly without exception
final freeResult = freeService.canAccess(feature);
final proResult = proService.canAccess(feature);
if (feature == MotorSignalFeature.cloudBackup) {
expect(
freeResult,
isTrue,
reason: 'Cloud backup included in free tier',
);
expect(proResult, isTrue);
} else {
expect(
freeResult,
isFalse,
reason: '$feature should be denied on Free tier',
);
expect(
proResult,
isTrue,
reason: '$feature should be granted on Pro tier',
);
}
}
});
});
group('Entitlement serialization & storage contract', () {
test('toJson and fromJson perform lossless round trip', () {
final original = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyActive,
verificationStatus: EntitlementVerificationStatus.verified,
source: PurchaseSource.googlePlay,
productId: 'motorsignal_pro_monthly',
purchaseTokenHash: 'token_sha256_hash',
expiresAt: DateTime.utc(2026, 10, 1),
lastVerifiedAt: DateTime.utc(2026, 9, 1),
);
final json = original.toJson();
final restored = EntitlementState.fromJson(json);
expect(restored.plan, original.plan);
expect(restored.status, original.status);
expect(restored.verificationStatus, original.verificationStatus);
expect(restored.source, original.source);
expect(restored.productId, original.productId);
expect(restored.purchaseTokenHash, original.purchaseTokenHash);
expect(restored.expiresAt, original.expiresAt);
expect(restored.lastVerifiedAt, original.lastVerifiedAt);
expect(restored.isPro, isTrue);
});
test('fromJson throws on unsupported future version', () {
final invalidVersionJson = {'version': 99, 'plan': 'proMonthly'};
expect(
() => EntitlementState.fromJson(invalidVersionJson),
throwsA(isA<FormatException>()),
);
});
test('InMemoryEntitlementStorage behaves according to contract', () async {
final storage = InMemoryEntitlementStorage();
// Missing cache defaults to Free
final initial = await storage.read();
expect(initial.plan, MotorSignalPlan.free);
expect(initial.isPro, isFalse);
// Write Pro Lifetime
final proLifetime = EntitlementState.proLifetime(productId: 'lifetime');
await storage.write(proLifetime);
final cached = await storage.read();
expect(cached.plan, MotorSignalPlan.proLifetime);
expect(cached.isPro, isTrue);
// Clear reverts to Free
await storage.clear();
final cleared = await storage.read();
expect(cleared.plan, MotorSignalPlan.free);
expect(cleared.isPro, isFalse);
});
});
group('Riverpod Provider integration', () {
test('featureAccessProvider reacts to entitlementProvider updates', () {
final container = ProviderContainer(
overrides: [
entitlementStorageProvider.overrideWithValue(
InMemoryEntitlementStorage(),
),
],
);
addTearDown(container.dispose);
// Default is Free
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isFalse,
);
// Update to Pro Lifetime
container.read(entitlementProvider.notifier).state =
EntitlementState.proLifetime();
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isTrue,
);
// Downgrade to Expired
container
.read(entitlementProvider.notifier)
.state = const EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyExpired,
);
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isFalse,
);
});
});
group('Architectural boundaries & Rider safety invariant', () {
test('entitlement domain requires zero Firebase or auth dependencies', () {
// Constructs, normalizes, serializes, queries access with no Firebase context
const state = EntitlementState.free();
const service = FeatureAccessService(state);
expect(service.canAccess(MotorSignalFeature.cloudBackup), isTrue);
});
});
}