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:
@ -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('''
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user