ponshu-room-lite/lib/services/license_service.dart

215 lines
8.2 KiB
Dart
Raw Normal View History

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'device_service.dart';
import '../secrets.dart';
/// ライセンス状態
enum LicenseStatus {
/// 無料版AI解析は1日50回まで利用可能
free,
/// Pro版ライセンス有効Pro機能すべて解放
pro,
/// ライセンス無効化済み(不正利用・返金等)
revoked,
/// オフライン / サーバー疎通不可(キャッシュ利用)
offline,
}
/// ライセンス管理サービス
///
/// ## ストレージ方針
/// - ライセンスキー本体: flutter_secure_storage暗号化
/// - 状態キャッシュ: SharedPreferences平文でもリスクなし
///
/// ## 状態管理の優先順位
/// 1. 起動時: SecureStorage のキャッシュを即時返却(ちらつき防止)
/// 2. バックグラウンド: VPS で再検証し、差異があれば状態を更新
/// 3. オフライン時: SharedPreferences のキャッシュを維持
///
/// ## ライセンスキー形式
/// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字)
class LicenseService {
static const _secureKeyName = 'ponshu_license_key';
static const _prefCachedStatus = 'ponshu_license_status_cache';
static const _prefCachedAt = 'ponshu_license_cached_at';
static const _prefMigratedV1 = 'ponshu_license_migrated_v1';
static const _cacheValidSeconds = 24 * 60 * 60; // 24時間
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
// ========== Migration ==========
/// SharedPreferences → flutter_secure_storage の一回限りのマイグレーション
static Future<void> _migrateIfNeeded() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_prefMigratedV1) == true) return;
// 旧ストレージにキーがあれば移行して削除
final oldValue = prefs.getString(_secureKeyName);
if (oldValue != null && oldValue.isNotEmpty) {
await _storage.write(key: _secureKeyName, value: oldValue);
await prefs.remove(_secureKeyName);
debugPrint('[License] Migrated license key to secure storage.');
}
await prefs.setBool(_prefMigratedV1, true);
}
// ========== Public API ==========
/// キャッシュのみを即時返却(サーバー問い合わせなし)
///
/// 起動時のちらつき防止用。main() で await してから runApp() に渡す。
static Future<LicenseStatus> getCachedStatusOnly() async {
await _migrateIfNeeded();
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
if (savedKey.isEmpty) return LicenseStatus.free;
final prefs = await SharedPreferences.getInstance();
return _getCachedStatus(prefs);
}
/// アプリ起動時(バックグラウンド): VPS でライセンス状態を検証して返す
static Future<LicenseStatus> checkStatus() async {
await _migrateIfNeeded();
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
if (savedKey.isNotEmpty) {
try {
final status = await _validateKeyWithServer(savedKey);
if (status == LicenseStatus.offline) {
debugPrint('[License] Server unreachable, using cache');
final prefs = await SharedPreferences.getInstance();
return _getCachedStatus(prefs);
}
final prefs = await SharedPreferences.getInstance();
await _cacheStatus(prefs, status);
return status;
} catch (e) {
debugPrint('[License] Server unreachable, using cache: $e');
final prefs = await SharedPreferences.getInstance();
return _getCachedStatus(prefs);
}
}
return LicenseStatus.free;
}
/// ライセンスキーをアクティベートする
///
/// 成功: true + 空メッセージ
/// 失敗: false + エラーメッセージ
static Future<({bool success, String message})> activate(String rawKey) async {
final key = rawKey.trim().toUpperCase();
if (!_isValidKeyFormat(key)) {
return (success: false, message: 'キーの形式が正しくありません\n(例: PONSHU-XXXX-XXXX-XXXX)');
}
final status = await _validateKeyWithServer(key);
if (status == LicenseStatus.pro) {
await _storage.write(key: _secureKeyName, value: key);
final prefs = await SharedPreferences.getInstance();
await _cacheStatus(prefs, LicenseStatus.pro);
debugPrint('[License] Activated successfully.');
return (success: true, message: '');
}
if (status == LicenseStatus.revoked) {
return (success: false, message: 'このライセンスは無効化されています。\nサポートにお問い合わせください。');
}
if (status == LicenseStatus.offline) {
return (success: false, message: 'サーバーに接続できませんでした。\nネットワーク接続を確認してください。');
}
return (success: false, message: 'ライセンスキーが見つかりません。\nご購入時のメールをご確認ください。');
}
/// ライセンスキーがローカルに保存されているか
static Future<bool> hasLicenseKey() async {
await _migrateIfNeeded();
return ((await _storage.read(key: _secureKeyName)) ?? '').isNotEmpty;
}
/// ライセンスをリセット(デバッグ用)
static Future<void> reset() async {
await _storage.delete(key: _secureKeyName);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefCachedStatus);
await prefs.remove(_prefCachedAt);
debugPrint('[License] Reset complete.');
}
// ========== Private Helpers ==========
static bool _isValidKeyFormat(String key) {
final regex = RegExp(r'^PONSHU-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$');
return regex.hasMatch(key);
}
static Future<LicenseStatus> _validateKeyWithServer(String key) async {
try {
final deviceId = await DeviceService.getDeviceId();
final response = await http.post(
Uri.parse('${Secrets.posimaiBaseUrl}/api/ponshu/license/validate'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'license_key': key, 'device_id': deviceId}),
).timeout(const Duration(seconds: 15));
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
if (data['valid'] == true) return LicenseStatus.pro;
// revoked フィールドbooleanを優先し、error メッセージ文字列にも対応
if (data['revoked'] == true ||
(data['error'] as String? ?? '').contains('無効化')) {
return LicenseStatus.revoked;
}
return LicenseStatus.free;
} catch (e) {
debugPrint('[License] Validation network error: $e');
return LicenseStatus.offline;
}
}
static Future<void> _cacheStatus(SharedPreferences prefs, LicenseStatus status) async {
await prefs.setString(_prefCachedStatus, status.name);
await prefs.setString(_prefCachedAt, DateTime.now().toIso8601String());
}
static LicenseStatus _getCachedStatus(SharedPreferences prefs) {
final cached = prefs.getString(_prefCachedStatus);
final cachedAt = prefs.getString(_prefCachedAt);
if (cached == null) return LicenseStatus.free;
// オンライン時は checkStatus が常に上書きするため、
// _getCachedStatus はオフライン時専用のフォールバックとして動作する。
//
// TTL 判定_cacheValidSeconds = 24h:
// - free / offline: 期限切れで free にフォールバック
// - pro : 購入者をオフライン時に締め出さないため永続扱い
// - revoked: 不正防止を優先するため永続扱い
if (cachedAt != null) {
final age = DateTime.now().difference(DateTime.parse(cachedAt));
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
return LicenseStatus.free;
}
}
return LicenseStatus.values.firstWhere(
(s) => s.name == cached,
orElse: () => LicenseStatus.free,
);
}
}