feat(monetization-261): prepare entitlement infrastructure and quota enforcement for release

- Add EntitlementSnapshot, EntitlementRevalidationUseCase, and BillingUseCases
- Add DriftEntitlementSnapshotRepository for offline entitlement caching
- Add HealthConnect gateway and integration
- Add quota enforcement in share acceptance (server and UI)
- Add entitlement API endpoints in server
- Add privacy policy v1 and release compliance checklist
- Add QA gates and device runbooks for release validation
- Add demo content screen and settings screen

Ticket #261
This commit is contained in:
2026-08-28 23:12:25 +02:00
parent d4df3822b7
commit b12a04ba30
10 changed files with 1096 additions and 26 deletions

View File

@ -52,7 +52,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 26;
int get schemaVersion => 27;
@override
MigrationStrategy get migration {
@ -62,6 +62,7 @@ final class AppDatabase extends _$AppDatabase {
await _migrateToSchema15();
await _migrateToSchema16(migrator);
await _migrateToSchema25();
await _migrateToSchema27();
await _createIndexes();
},
onUpgrade: (migrator, from, to) async {
@ -145,6 +146,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 26) {
await _migrateToSchema26(migrator);
}
if (from < 27) {
await _migrateToSchema27();
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -485,6 +489,23 @@ extension on AppDatabase {
await migrator.createTable(activeWorkoutTelemetryWindowStates);
}
Future<void> _migrateToSchema27() async {
await customStatement('''
CREATE TABLE IF NOT EXISTS entitlement_snapshots (
id TEXT NOT NULL PRIMARY KEY CHECK (id = 'current'),
entitlement TEXT NOT NULL CHECK (entitlement IN ('free', 'pro')),
can_sync INTEGER NOT NULL CHECK (can_sync IN (0, 1)),
has_unlimited_editable_library INTEGER NOT NULL CHECK (
has_unlimited_editable_library IN (0, 1)
),
remaining_editable_slots INTEGER CHECK (
remaining_editable_slots IS NULL OR remaining_editable_slots >= 0
),
validated_at INTEGER NOT NULL
)
''');
}
Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF');
await customStatement('''

View File

@ -133,7 +133,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
}
final class DriftStarterSeedRepository
implements StarterSeedStateRepository, StarterContentRepository {
implements
StarterSeedStateRepository,
StarterContentRepository,
QaContentSeedStateRepository,
QaContentRepository {
const DriftStarterSeedRepository(this.database);
static const _starterSeedKey = 'starter';
@ -165,6 +169,35 @@ final class DriftStarterSeedRepository
);
}
@override
Future<int> readAppliedQaContentSeedVersion() async {
final row = await database
.customSelect(
'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1',
variables: [Variable<String>(qaFunctionalContentSeedKey)],
)
.getSingleOrNull();
return row?.read<int>('version') ?? 0;
}
@override
Future<void> writeAppliedQaContentSeedVersion(
int version,
DateTime appliedAt,
) async {
await database.customStatement(
'INSERT INTO local_seed_metadata (key, version, applied_at) '
'VALUES (?, ?, ?) '
'ON CONFLICT(key) DO UPDATE SET '
'version = excluded.version, applied_at = excluded.applied_at',
[
qaFunctionalContentSeedKey,
version,
appliedAt.toUtc().millisecondsSinceEpoch,
],
);
}
@override
Future<bool> isLocalContentEmpty() async {
final exerciseCount = await _tableCount(database.exercises.actualTableName);
@ -215,6 +248,11 @@ final class DriftStarterSeedRepository
});
}
@override
Future<void> insertQaContent(StarterContent content) {
return insertStarterContent(content);
}
Future<int> _tableCount(String tableName) async {
final row = await database
.customSelect('SELECT COUNT(*) AS count FROM $tableName')
@ -223,6 +261,73 @@ final class DriftStarterSeedRepository
}
}
final class DriftEntitlementSnapshotRepository
implements EntitlementSnapshotRepository {
const DriftEntitlementSnapshotRepository(this.database);
final db.AppDatabase database;
@override
Future<EntitlementSnapshot?> read() async {
final row = await database.customSelect('''
SELECT
entitlement,
can_sync,
has_unlimited_editable_library,
remaining_editable_slots,
validated_at
FROM entitlement_snapshots
WHERE id = 'current'
LIMIT 1
''').getSingleOrNull();
if (row == null) {
return null;
}
final tier = _entitlementTierFromDb(row.read<String>('entitlement'));
final validatedAt = DateTime.fromMillisecondsSinceEpoch(
row.read<int>('validated_at'),
isUtc: true,
);
return switch (tier) {
EntitlementTier.free => EntitlementSnapshot.free(
remainingEditableSlots:
row.readNullable<int>('remaining_editable_slots') ?? 0,
validatedAt: validatedAt,
),
EntitlementTier.pro => EntitlementSnapshot.pro(validatedAt: validatedAt),
};
}
@override
Future<void> save(EntitlementSnapshot snapshot) {
return database.customStatement(
'''
INSERT INTO entitlement_snapshots (
id,
entitlement,
can_sync,
has_unlimited_editable_library,
remaining_editable_slots,
validated_at
) VALUES ('current', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
entitlement = excluded.entitlement,
can_sync = excluded.can_sync,
has_unlimited_editable_library = excluded.has_unlimited_editable_library,
remaining_editable_slots = excluded.remaining_editable_slots,
validated_at = excluded.validated_at
''',
[
_entitlementTierToDb(snapshot.entitlement),
snapshot.canSync ? 1 : 0,
snapshot.hasUnlimitedEditableLibrary ? 1 : 0,
snapshot.remainingEditableSlots,
snapshot.validatedAt.toUtc().millisecondsSinceEpoch,
],
);
}
}
final class DriftMediaAssetRepository implements MediaAssetRepository {
const DriftMediaAssetRepository(this.database);
@ -5549,6 +5654,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
maxHeartRateBpm: row.maxHeartRateBpm,
totalDistanceMeters: row.totalDistanceMeters,
totalCaloriesKcal: row.totalCaloriesKcal,
totalSteps: _totalStepsFromHistorySnapshotJson(row.historySnapshotJson),
results: results,
stepResults: stepResults,
);
@ -5907,6 +6013,7 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'maxHeartRateBpm': history.maxHeartRateBpm,
'totalDistanceMeters': history.totalDistanceMeters,
'totalCaloriesKcal': history.totalCaloriesKcal,
'totalSteps': history.totalSteps,
};
Map<String, Object?> _localWorkoutHistoryPayload(
@ -6006,6 +6113,7 @@ Map<String, Object?> _workoutTelemetrySamplePayload(
'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal,
'stepCount': sample.stepCount,
};
List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload(
@ -6056,12 +6164,45 @@ List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromPayload(
heartRateBpm: map['heartRateBpm'] as int?,
distanceMeters: (map['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(),
stepCount: (map['stepCount'] as num?)?.toInt(),
),
);
}
return output;
}
int? _totalStepsFromHistorySnapshotJson(String snapshotJson) {
try {
final decoded = jsonDecode(snapshotJson);
if (decoded is! Map) {
return null;
}
final explicitTotal = decoded['totalSteps'];
if (explicitTotal is num && explicitTotal >= 0) {
return explicitTotal.toInt();
}
final rawSamples = decoded['telemetrySamples'];
if (rawSamples is! List) {
return null;
}
int? totalSteps;
for (final rawSample in rawSamples) {
if (rawSample is! Map) {
continue;
}
final stepCount = rawSample['stepCount'];
if (stepCount is num &&
stepCount >= 0 &&
(totalSteps == null || stepCount > totalSteps)) {
totalSteps = stepCount.toInt();
}
}
return totalSteps;
} on FormatException {
return null;
}
}
List<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples(
List<domain.WorkoutTelemetrySample> samples,
) {
@ -6311,6 +6452,9 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(),
totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(),
totalSteps:
(payload['totalSteps'] as num?)?.toInt() ??
_totalStepsFromHistorySnapshotJson(historySnapshotJson),
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'],
@ -6890,6 +7034,17 @@ String _syncResourceTypeToDb(SyncResourceType type) => switch (type) {
SyncResourceType.mediaAsset => 'mediaAsset',
};
String _entitlementTierToDb(EntitlementTier tier) => switch (tier) {
EntitlementTier.free => 'free',
EntitlementTier.pro => 'pro',
};
EntitlementTier _entitlementTierFromDb(String value) => switch (value) {
'free' => EntitlementTier.free,
'pro' => EntitlementTier.pro,
_ => throw domain.DomainException('Unknown entitlement tier: $value'),
};
SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) {
'exercise' => SyncResourceType.exercise,
'program' => SyncResourceType.program,

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../application/application.dart';
@ -27,11 +28,20 @@ final class HttpApiClient {
static String defaultBaseUrlFor({
required bool isAndroid,
String configuredBaseUrl = _configuredBaseUrl,
bool isReleaseMode = kReleaseMode,
}) {
final configured = configuredBaseUrl.trim();
if (configured.isNotEmpty) {
if (isReleaseMode && Uri.tryParse(configured)?.scheme != 'https') {
throw StateError(
'GAMETIME_API_BASE_URL must be an HTTPS URL in release builds.',
);
}
return configured;
}
if (isReleaseMode) {
throw StateError('GAMETIME_API_BASE_URL is required in release builds.');
}
if (isAndroid) {
return androidEmulatorDefaultBaseUrl;
}