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 _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 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 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 hasLicenseKey() async { await _migrateIfNeeded(); return ((await _storage.read(key: _secureKeyName)) ?? '').isNotEmpty; } /// ライセンスをリセット(デバッグ用) static Future 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 _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; 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 _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, ); } }