import 'dart:convert'; import 'package:flutter/foundation.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, } /// ライセンス管理サービス /// /// ## 状態管理の優先順位 /// 1. オンライン時: VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ /// 2. オフライン時: SharedPreferencesのキャッシュを使用 (Pro状態を維持) /// /// ## ライセンスキー形式 /// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字) class LicenseService { static const _prefLicenseKey = 'ponshu_license_key'; static const _prefCachedStatus = 'ponshu_license_status_cache'; static const _prefCachedAt = 'ponshu_license_cached_at'; static const _cacheValidSeconds = 24 * 60 * 60; // 24時間キャッシュ有効 // ========== Public API ========== /// アプリ起動時に呼ぶ: ライセンス状態を確認して返す static Future checkStatus() async { final prefs = await SharedPreferences.getInstance(); final savedKey = prefs.getString(_prefLicenseKey) ?? ''; // ライセンスキーが保存済み → サーバーで検証 if (savedKey.isNotEmpty) { try { final status = await _validateKeyWithServer(savedKey); if (status == LicenseStatus.offline) { // ネットワーク不通: キャッシュを上書きせずに返す debugPrint('[License] Server unreachable, using cache'); return _getCachedStatus(prefs); } await _cacheStatus(prefs, status); return status; } catch (e) { debugPrint('[License] Server unreachable, using cache: $e'); 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) { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefLicenseKey, key); 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 { final prefs = await SharedPreferences.getInstance(); return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty; } /// ライセンスをリセット(デバッグ用) static Future reset() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefLicenseKey); 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; if ((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; // キャッシュが古すぎる場合はfreeにフォールバック if (cachedAt != null) { final age = DateTime.now().difference(DateTime.parse(cachedAt)); if (age.inSeconds > _cacheValidSeconds && cached != LicenseStatus.pro.name) { return LicenseStatus.free; } } // Pro キャッシュはオフラインでも維持(購入者を締め出さない) return LicenseStatus.values.firstWhere( (s) => s.name == cached, orElse: () => LicenseStatus.free, ); } }